pyinfra 3.1.1__py2.py3-none-any.whl → 3.3__py2.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 (104) hide show
  1. pyinfra/api/arguments.py +9 -2
  2. pyinfra/api/arguments_typed.py +4 -5
  3. pyinfra/api/command.py +22 -3
  4. pyinfra/api/config.py +5 -2
  5. pyinfra/api/deploy.py +4 -2
  6. pyinfra/api/facts.py +3 -0
  7. pyinfra/api/host.py +15 -7
  8. pyinfra/api/operation.py +2 -1
  9. pyinfra/api/state.py +1 -1
  10. pyinfra/connectors/base.py +34 -8
  11. pyinfra/connectors/chroot.py +7 -2
  12. pyinfra/connectors/docker.py +24 -8
  13. pyinfra/connectors/dockerssh.py +7 -2
  14. pyinfra/connectors/local.py +7 -2
  15. pyinfra/connectors/ssh.py +9 -2
  16. pyinfra/connectors/sshuserclient/client.py +42 -14
  17. pyinfra/connectors/sshuserclient/config.py +2 -0
  18. pyinfra/connectors/terraform.py +1 -1
  19. pyinfra/connectors/util.py +13 -9
  20. pyinfra/context.py +9 -2
  21. pyinfra/facts/apk.py +8 -1
  22. pyinfra/facts/apt.py +68 -0
  23. pyinfra/facts/brew.py +13 -0
  24. pyinfra/facts/bsdinit.py +3 -0
  25. pyinfra/facts/cargo.py +5 -0
  26. pyinfra/facts/choco.py +6 -0
  27. pyinfra/facts/crontab.py +195 -0
  28. pyinfra/facts/deb.py +10 -0
  29. pyinfra/facts/dnf.py +5 -0
  30. pyinfra/facts/docker.py +16 -0
  31. pyinfra/facts/efibootmgr.py +113 -0
  32. pyinfra/facts/files.py +112 -7
  33. pyinfra/facts/flatpak.py +7 -0
  34. pyinfra/facts/freebsd.py +75 -0
  35. pyinfra/facts/gem.py +5 -0
  36. pyinfra/facts/git.py +12 -2
  37. pyinfra/facts/gpg.py +7 -0
  38. pyinfra/facts/hardware.py +13 -0
  39. pyinfra/facts/iptables.py +9 -1
  40. pyinfra/facts/launchd.py +5 -0
  41. pyinfra/facts/lxd.py +5 -0
  42. pyinfra/facts/mysql.py +9 -2
  43. pyinfra/facts/npm.py +5 -0
  44. pyinfra/facts/openrc.py +8 -0
  45. pyinfra/facts/opkg.py +245 -0
  46. pyinfra/facts/pacman.py +9 -1
  47. pyinfra/facts/pip.py +5 -0
  48. pyinfra/facts/pipx.py +82 -0
  49. pyinfra/facts/pkg.py +4 -0
  50. pyinfra/facts/pkgin.py +5 -0
  51. pyinfra/facts/podman.py +54 -0
  52. pyinfra/facts/postgres.py +10 -2
  53. pyinfra/facts/rpm.py +11 -0
  54. pyinfra/facts/runit.py +7 -0
  55. pyinfra/facts/selinux.py +16 -0
  56. pyinfra/facts/server.py +87 -79
  57. pyinfra/facts/snap.py +7 -0
  58. pyinfra/facts/systemd.py +5 -0
  59. pyinfra/facts/sysvinit.py +4 -0
  60. pyinfra/facts/upstart.py +5 -0
  61. pyinfra/facts/util/__init__.py +4 -1
  62. pyinfra/facts/util/units.py +30 -0
  63. pyinfra/facts/vzctl.py +5 -0
  64. pyinfra/facts/xbps.py +6 -1
  65. pyinfra/facts/yum.py +5 -0
  66. pyinfra/facts/zfs.py +41 -21
  67. pyinfra/facts/zypper.py +5 -0
  68. pyinfra/local.py +3 -2
  69. pyinfra/operations/apt.py +36 -22
  70. pyinfra/operations/crontab.py +189 -0
  71. pyinfra/operations/docker.py +61 -56
  72. pyinfra/operations/files.py +65 -1
  73. pyinfra/operations/freebsd/__init__.py +12 -0
  74. pyinfra/operations/freebsd/freebsd_update.py +70 -0
  75. pyinfra/operations/freebsd/pkg.py +219 -0
  76. pyinfra/operations/freebsd/service.py +116 -0
  77. pyinfra/operations/freebsd/sysrc.py +92 -0
  78. pyinfra/operations/git.py +23 -7
  79. pyinfra/operations/opkg.py +88 -0
  80. pyinfra/operations/pip.py +3 -2
  81. pyinfra/operations/pipx.py +90 -0
  82. pyinfra/operations/postgres.py +114 -27
  83. pyinfra/operations/runit.py +2 -0
  84. pyinfra/operations/server.py +9 -181
  85. pyinfra/operations/util/docker.py +44 -22
  86. pyinfra/operations/zfs.py +3 -3
  87. {pyinfra-3.1.1.dist-info → pyinfra-3.3.dist-info}/LICENSE.md +1 -1
  88. {pyinfra-3.1.1.dist-info → pyinfra-3.3.dist-info}/METADATA +25 -25
  89. pyinfra-3.3.dist-info/RECORD +187 -0
  90. pyinfra_cli/exceptions.py +5 -0
  91. pyinfra_cli/inventory.py +26 -9
  92. pyinfra_cli/log.py +3 -0
  93. pyinfra_cli/main.py +9 -8
  94. pyinfra_cli/prints.py +19 -4
  95. pyinfra_cli/util.py +3 -0
  96. pyinfra_cli/virtualenv.py +1 -1
  97. tests/test_cli/test_cli_deploy.py +15 -13
  98. tests/test_cli/test_cli_inventory.py +53 -0
  99. tests/test_connectors/test_ssh.py +302 -182
  100. tests/test_connectors/test_sshuserclient.py +68 -1
  101. pyinfra-3.1.1.dist-info/RECORD +0 -172
  102. {pyinfra-3.1.1.dist-info → pyinfra-3.3.dist-info}/WHEEL +0 -0
  103. {pyinfra-3.1.1.dist-info → pyinfra-3.3.dist-info}/entry_points.txt +0 -0
  104. {pyinfra-3.1.1.dist-info → pyinfra-3.3.dist-info}/top_level.txt +0 -0
@@ -8,6 +8,7 @@ All operations in this module take four optional arguments:
8
8
  + ``psql_password``: the password for the connecting user
9
9
  + ``psql_host``: the hostname of the server to connect to
10
10
  + ``psql_port``: the port of the server to connect to
11
+ + ``psql_database``: the database on the server to connect to
11
12
 
12
13
  See example/postgresql.py for detailed example
13
14
 
@@ -28,48 +29,48 @@ from pyinfra.facts.postgres import (
28
29
  @operation(is_idempotent=False)
29
30
  def sql(
30
31
  sql: str,
31
- database: str | None = None,
32
32
  # Details for speaking to PostgreSQL via `psql` CLI
33
33
  psql_user: str | None = None,
34
34
  psql_password: str | None = None,
35
35
  psql_host: str | None = None,
36
36
  psql_port: int | None = None,
37
+ psql_database: str | None = None,
37
38
  ):
38
39
  """
39
40
  Execute arbitrary SQL against PostgreSQL.
40
41
 
41
42
  + sql: SQL command(s) to execute
42
- + database: optional database to execute against
43
43
  + psql_*: global module arguments, see above
44
44
  """
45
45
 
46
46
  yield make_execute_psql_command(
47
47
  sql,
48
- database=database,
49
48
  user=psql_user,
50
49
  password=psql_password,
51
50
  host=psql_host,
52
51
  port=psql_port,
52
+ database=psql_database,
53
53
  )
54
54
 
55
55
 
56
56
  @operation()
57
57
  def role(
58
58
  role: str,
59
- present=True,
59
+ present: bool = True,
60
60
  password: str | None = None,
61
- login=True,
62
- superuser=False,
63
- inherit=False,
64
- createdb=False,
65
- createrole=False,
66
- replication=False,
61
+ login: bool = True,
62
+ superuser: bool = False,
63
+ inherit: bool = False,
64
+ createdb: bool = False,
65
+ createrole: bool = False,
66
+ replication: bool = False,
67
67
  connection_limit: int | None = None,
68
68
  # Details for speaking to PostgreSQL via `psql` CLI
69
69
  psql_user: str | None = None,
70
70
  psql_password: str | None = None,
71
71
  psql_host: str | None = None,
72
72
  psql_port: int | None = None,
73
+ psql_database: str | None = None,
73
74
  ):
74
75
  """
75
76
  Add/remove PostgreSQL roles.
@@ -101,7 +102,7 @@ def role(
101
102
  password="somepassword",
102
103
  superuser=True,
103
104
  login=True,
104
- sudo_user="postgres",
105
+ _sudo_user="postgres",
105
106
  )
106
107
 
107
108
  """
@@ -112,6 +113,7 @@ def role(
112
113
  psql_password=psql_password,
113
114
  psql_host=psql_host,
114
115
  psql_port=psql_port,
116
+ psql_database=psql_database,
115
117
  )
116
118
 
117
119
  is_present = role in roles
@@ -125,6 +127,7 @@ def role(
125
127
  password=psql_password,
126
128
  host=psql_host,
127
129
  port=psql_port,
130
+ database=psql_database,
128
131
  )
129
132
  else:
130
133
  host.noop("postgresql role {0} does not exist".format(role))
@@ -157,9 +160,50 @@ def role(
157
160
  password=psql_password,
158
161
  host=psql_host,
159
162
  port=psql_port,
163
+ database=psql_database,
160
164
  )
161
165
  else:
162
- host.noop("postgresql role {0} exists".format(role))
166
+ # Check if any attributes need updating
167
+ current_role = roles[role]
168
+ should_execute = False
169
+ sql_bits = ['ALTER ROLE "{0}"'.format(role)]
170
+ if login and "login" in current_role and current_role["login"] != login:
171
+ sql_bits.append("LOGIN")
172
+ should_execute = True
173
+ if superuser and "superuser" in current_role and current_role["superuser"] != superuser:
174
+ sql_bits.append("SUPERUSER")
175
+ should_execute = True
176
+ if inherit and "inherit" in current_role and current_role["inherit"] != inherit:
177
+ sql_bits.append("INHERIT")
178
+ should_execute = True
179
+ if createdb and "createdb" in current_role and current_role["createdb"] != createdb:
180
+ sql_bits.append("CREATEDB")
181
+ should_execute = True
182
+ if createrole and "createrole" in current_role and current_role["createrole"] != createrole:
183
+ sql_bits.append("CREATEROLE")
184
+ should_execute = True
185
+ if (
186
+ connection_limit
187
+ and "connection_limit" in current_role
188
+ and roles[role]["connection_limit"] != connection_limit
189
+ ):
190
+ sql_bits.append("CONNECTION LIMIT {0}".format(connection_limit))
191
+ should_execute = True
192
+ if password:
193
+ sql_bits.append(MaskString("PASSWORD '{0}'".format(password)))
194
+ should_execute = True
195
+
196
+ if should_execute:
197
+ yield make_execute_psql_command(
198
+ StringCommand(*sql_bits),
199
+ user=psql_user,
200
+ password=psql_password,
201
+ host=psql_host,
202
+ port=psql_port,
203
+ database=psql_database,
204
+ )
205
+ else:
206
+ host.noop("postgresql role {0} exists and does not need updates".format(role))
163
207
 
164
208
 
165
209
  @operation()
@@ -178,6 +222,7 @@ def database(
178
222
  psql_password: str | None = None,
179
223
  psql_host: str | None = None,
180
224
  psql_port: int | None = None,
225
+ psql_database: str | None = None,
181
226
  ):
182
227
  """
183
228
  Add/remove PostgreSQL databases.
@@ -194,9 +239,8 @@ def database(
194
239
  + psql_*: global module arguments, see above
195
240
 
196
241
  Updates:
197
- pyinfra will not attempt to change existing databases - it will either
198
- create or drop databases, but not alter them (if the db exists this
199
- operation will make no changes).
242
+ pyinfra will change existing databases - but some parameters are not
243
+ changeable (template, encoding, lc_collate and lc_ctype).
200
244
 
201
245
  **Example:**
202
246
 
@@ -207,7 +251,7 @@ def database(
207
251
  database="pyinfra_stuff",
208
252
  owner="pyinfra",
209
253
  encoding="UTF8",
210
- sudo_user="postgres",
254
+ _sudo_user="postgres",
211
255
  )
212
256
 
213
257
  """
@@ -218,6 +262,7 @@ def database(
218
262
  psql_password=psql_password,
219
263
  psql_host=psql_host,
220
264
  psql_port=psql_port,
265
+ psql_database=psql_database,
221
266
  )
222
267
 
223
268
  is_present = database in current_databases
@@ -230,6 +275,7 @@ def database(
230
275
  password=psql_password,
231
276
  host=psql_host,
232
277
  port=psql_port,
278
+ database=psql_database,
233
279
  )
234
280
  else:
235
281
  host.noop("postgresql database {0} does not exist".format(database))
@@ -243,8 +289,8 @@ def database(
243
289
  ("OWNER", '"{0}"'.format(owner) if owner else owner),
244
290
  ("TEMPLATE", template),
245
291
  ("ENCODING", encoding),
246
- ("LC_COLLATE", lc_collate),
247
- ("LC_CTYPE", lc_ctype),
292
+ ("LC_COLLATE", "'{0}'".format(lc_collate) if lc_collate else lc_collate),
293
+ ("LC_CTYPE", "'{0}'".format(lc_ctype) if lc_ctype else lc_ctype),
248
294
  ("TABLESPACE", tablespace),
249
295
  ("CONNECTION LIMIT", connection_limit),
250
296
  ):
@@ -257,26 +303,70 @@ def database(
257
303
  password=psql_password,
258
304
  host=psql_host,
259
305
  port=psql_port,
306
+ database=psql_database,
260
307
  )
261
308
  else:
262
- host.noop("postgresql database {0} exists".format(database))
309
+ current_db = current_databases[database]
310
+
311
+ for key, value, current_value in (
312
+ ("TEMPLATE", template, current_db.get("istemplate")),
313
+ ("ENCODING", encoding, current_db.get("encoding")),
314
+ ("LC_COLLATE", lc_collate, None),
315
+ ("LC_CTYPE", lc_ctype, None),
316
+ ):
317
+ if value and (current_value is None or current_value != value):
318
+ host.noop(
319
+ "postgresql database {0} already exists, skipping {1}".format(database, key)
320
+ )
321
+
322
+ sql_bits = []
323
+
324
+ if owner and "owner" in current_db and current_db["owner"] != owner:
325
+ sql_bits.append('ALTER DATABASE "{0}" OWNER TO "{1}";'.format(database, owner))
326
+
327
+ if tablespace and "tablespace" in current_db and current_db["tablespace"] != tablespace:
328
+ sql_bits.append(
329
+ 'ALTER DATABASE "{0}" SET TABLESPACE "{1}";'.format(database, tablespace)
330
+ )
331
+
332
+ if (
333
+ connection_limit
334
+ and "connlimit" in current_db
335
+ and current_db["connlimit"] != connection_limit
336
+ ):
337
+ sql_bits.append(
338
+ 'ALTER DATABASE "{0}" CONNECTION LIMIT {1};'.format(database, connection_limit)
339
+ )
340
+
341
+ if len(sql_bits) > 0:
342
+ yield make_execute_psql_command(
343
+ StringCommand(*sql_bits),
344
+ user=psql_user,
345
+ password=psql_password,
346
+ host=psql_host,
347
+ port=psql_port,
348
+ database=psql_database,
349
+ )
350
+ else:
351
+ host.noop(
352
+ "postgresql database {0} already exists with the same parameters".format(database)
353
+ )
263
354
 
264
355
 
265
356
  @operation(is_idempotent=False)
266
357
  def dump(
267
358
  dest: str,
268
- database: str | None = None,
269
359
  # Details for speaking to PostgreSQL via `psql` CLI
270
360
  psql_user: str | None = None,
271
361
  psql_password: str | None = None,
272
362
  psql_host: str | None = None,
273
363
  psql_port: int | None = None,
364
+ psql_database: str | None = None,
274
365
  ):
275
366
  """
276
367
  Dump a PostgreSQL database into a ``.sql`` file. Requires ``pg_dump``.
277
368
 
278
369
  + dest: name of the file to dump the SQL to
279
- + database: name of the database to dump
280
370
  + psql_*: global module arguments, see above
281
371
 
282
372
  **Example:**
@@ -286,7 +376,6 @@ def dump(
286
376
  postgresql.dump(
287
377
  name="Dump the pyinfra_stuff database",
288
378
  dest="/tmp/pyinfra_stuff.dump",
289
- database="pyinfra_stuff",
290
379
  sudo_user="postgres",
291
380
  )
292
381
 
@@ -295,11 +384,11 @@ def dump(
295
384
  yield StringCommand(
296
385
  make_psql_command(
297
386
  executable="pg_dump",
298
- database=database,
299
387
  user=psql_user,
300
388
  password=psql_password,
301
389
  host=psql_host,
302
390
  port=psql_port,
391
+ database=psql_database,
303
392
  ),
304
393
  ">",
305
394
  QuoteString(dest),
@@ -309,18 +398,17 @@ def dump(
309
398
  @operation(is_idempotent=False)
310
399
  def load(
311
400
  src: str,
312
- database: str | None = None,
313
401
  # Details for speaking to PostgreSQL via `psql` CLI
314
402
  psql_user: str | None = None,
315
403
  psql_password: str | None = None,
316
404
  psql_host: str | None = None,
317
405
  psql_port: int | None = None,
406
+ psql_database: str | None = None,
318
407
  ):
319
408
  """
320
409
  Load ``.sql`` file into a database.
321
410
 
322
411
  + src: the filename to read from
323
- + database: name of the database to import into
324
412
  + psql_*: global module arguments, see above
325
413
 
326
414
  **Example:**
@@ -330,7 +418,6 @@ def load(
330
418
  postgresql.load(
331
419
  name="Import the pyinfra_stuff dump into pyinfra_stuff_copy",
332
420
  src="/tmp/pyinfra_stuff.dump",
333
- database="pyinfra_stuff_copy",
334
421
  sudo_user="postgres",
335
422
  )
336
423
 
@@ -338,11 +425,11 @@ def load(
338
425
 
339
426
  yield StringCommand(
340
427
  make_psql_command(
341
- database=database,
342
428
  user=psql_user,
343
429
  password=psql_password,
344
430
  host=psql_host,
345
431
  port=psql_port,
432
+ database=psql_database,
346
433
  ),
347
434
  "<",
348
435
  QuoteString(src),
@@ -2,6 +2,8 @@
2
2
  Manage runit services.
3
3
  """
4
4
 
5
+ from __future__ import annotations
6
+
5
7
  from typing import Optional
6
8
 
7
9
  from pyinfra import host
@@ -5,7 +5,6 @@ Linux/BSD.
5
5
 
6
6
  from __future__ import annotations
7
7
 
8
- import shlex
9
8
  from io import StringIO
10
9
  from itertools import filterfalse, tee
11
10
  from os import path
@@ -18,7 +17,6 @@ from pyinfra.api.util import try_int
18
17
  from pyinfra.connectors.util import remove_any_sudo_askpass_file
19
18
  from pyinfra.facts.files import Directory, FindInFile, Link
20
19
  from pyinfra.facts.server import (
21
- Crontab,
22
20
  Groups,
23
21
  Home,
24
22
  Hostname,
@@ -30,6 +28,7 @@ from pyinfra.facts.server import (
30
28
  Users,
31
29
  Which,
32
30
  )
31
+ from pyinfra.operations import crontab as crontab_
33
32
 
34
33
  from . import (
35
34
  apk,
@@ -49,7 +48,7 @@ from . import (
49
48
  yum,
50
49
  zypper,
51
50
  )
52
- from .util.files import chmod, sed_replace
51
+ from .util.files import chmod
53
52
 
54
53
  if TYPE_CHECKING:
55
54
  from pyinfra.api.arguments_typed import PyinfraOperation
@@ -438,7 +437,7 @@ def sysctl(
438
437
  existing_sysctls = host.get_fact(Sysctl, keys=[key])
439
438
  existing_value = existing_sysctls.get(key)
440
439
 
441
- if not existing_value or existing_value != value:
440
+ if existing_value != value:
442
441
  yield "sysctl {0}='{1}'".format(key, string_value)
443
442
  else:
444
443
  host.noop("sysctl {0} is set to {1}".format(key, string_value))
@@ -588,180 +587,7 @@ def packages(
588
587
  yield from package_operation._inner(packages=packages, present=present)
589
588
 
590
589
 
591
- @operation()
592
- def crontab(
593
- command: str,
594
- present=True,
595
- user: str | None = None,
596
- cron_name: str | None = None,
597
- minute="*",
598
- hour="*",
599
- month="*",
600
- day_of_week="*",
601
- day_of_month="*",
602
- special_time: str | None = None,
603
- interpolate_variables=False,
604
- ):
605
- """
606
- Add/remove/update crontab entries.
607
-
608
- + command: the command for the cron
609
- + present: whether this cron command should exist
610
- + user: the user whose crontab to manage
611
- + cron_name: name the cronjob so future changes to the command will overwrite
612
- + minute: which minutes to execute the cron
613
- + hour: which hours to execute the cron
614
- + month: which months to execute the cron
615
- + day_of_week: which day of the week to execute the cron
616
- + day_of_month: which day of the month to execute the cron
617
- + special_time: cron "nickname" time (@reboot, @daily, etc), overrides others
618
- + interpolate_variables: whether to interpolate variables in ``command``
619
-
620
- Cron commands:
621
- Unless ``name`` is specified the command is used to identify crontab entries.
622
- This means commands must be unique within a given users crontab. If you require
623
- multiple identical commands, provide a different name argument for each.
624
-
625
- Special times:
626
- When provided, ``special_time`` will be used instead of any values passed in
627
- for ``minute``/``hour``/``month``/``day_of_week``/``day_of_month``.
628
-
629
- **Example:**
630
-
631
- .. code:: python
632
-
633
- # simple example for a crontab
634
- server.crontab(
635
- name="Backup /etc weekly",
636
- command="/bin/tar cf /tmp/etc_bup.tar /etc",
637
- name="backup_etc",
638
- day_of_week=0,
639
- hour=1,
640
- minute=0,
641
- )
642
- """
643
-
644
- def comma_sep(value):
645
- if isinstance(value, (list, tuple)):
646
- return ",".join("{0}".format(v) for v in value)
647
- return value
648
-
649
- minute = comma_sep(minute)
650
- hour = comma_sep(hour)
651
- month = comma_sep(month)
652
- day_of_week = comma_sep(day_of_week)
653
- day_of_month = comma_sep(day_of_month)
654
-
655
- crontab = host.get_fact(Crontab, user=user)
656
- name_comment = "# pyinfra-name={0}".format(cron_name)
657
-
658
- existing_crontab = crontab.get(command)
659
- existing_crontab_command = command
660
- existing_crontab_match = command
661
-
662
- if not existing_crontab and cron_name: # find the crontab by name if provided
663
- for cmd, details in crontab.items():
664
- if not details["comments"]:
665
- continue
666
- if name_comment in details["comments"]:
667
- existing_crontab = details
668
- existing_crontab_match = cmd
669
- existing_crontab_command = cmd
670
-
671
- exists = existing_crontab is not None
672
-
673
- edit_commands: list[str | StringCommand] = []
674
- temp_filename = host.get_temp_filename()
675
-
676
- if special_time:
677
- new_crontab_line = "{0} {1}".format(special_time, command)
678
- else:
679
- new_crontab_line = "{minute} {hour} {day_of_month} {month} {day_of_week} {command}".format(
680
- minute=minute,
681
- hour=hour,
682
- day_of_month=day_of_month,
683
- month=month,
684
- day_of_week=day_of_week,
685
- command=command,
686
- )
687
-
688
- existing_crontab_match = ".*{0}.*".format(existing_crontab_match)
689
-
690
- # Don't want the cron and it does exist? Remove the line
691
- if not present and exists:
692
- edit_commands.append(
693
- sed_replace(
694
- temp_filename,
695
- existing_crontab_match,
696
- "",
697
- interpolate_variables=interpolate_variables,
698
- ),
699
- )
700
-
701
- # Want the cron but it doesn't exist? Append the line
702
- elif present and not exists:
703
- if cron_name:
704
- if crontab: # append a blank line if cron entries already exist
705
- edit_commands.append("echo '' >> {0}".format(temp_filename))
706
- edit_commands.append(
707
- "echo {0} >> {1}".format(
708
- shlex.quote(name_comment),
709
- temp_filename,
710
- ),
711
- )
712
-
713
- edit_commands.append(
714
- "echo {0} >> {1}".format(
715
- shlex.quote(new_crontab_line),
716
- temp_filename,
717
- ),
718
- )
719
-
720
- # We have the cron and it exists, do it's details? If not, replace the line
721
- elif present and exists:
722
- assert existing_crontab is not None
723
- if any(
724
- (
725
- special_time != existing_crontab.get("special_time"),
726
- try_int(minute) != existing_crontab.get("minute"),
727
- try_int(hour) != existing_crontab.get("hour"),
728
- try_int(month) != existing_crontab.get("month"),
729
- try_int(day_of_week) != existing_crontab.get("day_of_week"),
730
- try_int(day_of_month) != existing_crontab.get("day_of_month"),
731
- existing_crontab_command != command,
732
- ),
733
- ):
734
- edit_commands.append(
735
- sed_replace(
736
- temp_filename,
737
- existing_crontab_match,
738
- new_crontab_line,
739
- interpolate_variables=interpolate_variables,
740
- ),
741
- )
742
-
743
- if edit_commands:
744
- crontab_args = []
745
- if user:
746
- crontab_args.append("-u {0}".format(user))
747
-
748
- # List the crontab into a temporary file if it exists
749
- if crontab:
750
- yield "crontab -l {0} > {1}".format(" ".join(crontab_args), temp_filename)
751
-
752
- # Now yield any edits
753
- for edit_command in edit_commands:
754
- yield edit_command
755
-
756
- # Finally, use the tempfile to write a new crontab
757
- yield "crontab {0} {1}".format(" ".join(crontab_args), temp_filename)
758
- else:
759
- host.noop(
760
- "crontab {0} {1}".format(
761
- command,
762
- "exists" if present else "does not exist",
763
- ),
764
- )
590
+ crontab = crontab_.crontab
765
591
 
766
592
 
767
593
  @operation()
@@ -938,6 +764,7 @@ def user(
938
764
  shell: str | None = None,
939
765
  group: str | None = None,
940
766
  groups: list[str] | None = None,
767
+ append=False,
941
768
  public_keys: str | list[str] | None = None,
942
769
  delete_keys=False,
943
770
  ensure_home=True,
@@ -945,7 +772,6 @@ def user(
945
772
  system=False,
946
773
  uid: int | None = None,
947
774
  comment: str | None = None,
948
- add_deploy_dir=True,
949
775
  unique=True,
950
776
  password: str | None = None,
951
777
  ):
@@ -958,14 +784,14 @@ def user(
958
784
  + shell: the users shell
959
785
  + group: the users primary group
960
786
  + groups: the users secondary groups
787
+ + append: whether to add `user` to `groups`, w/o losing membership of other groups
961
788
  + public_keys: list of public keys to attach to this user, ``home`` must be specified
962
789
  + delete_keys: whether to remove any keys not specified in ``public_keys``
963
790
  + ensure_home: whether to ensure the ``home`` directory exists
964
- + create_home: whether to new user create home directories from the system skeleton
791
+ + create_home: whether user create new user home directories from the system skeleton
965
792
  + system: whether to create a system account
966
793
  + uid: use a specific userid number
967
794
  + comment: the user GECOS comment
968
- + add_deploy_dir: any public_key filenames are relative to the deploy directory
969
795
  + unique: prevent creating users with duplicate UID
970
796
  + password: set the encrypted password for the user
971
797
 
@@ -1105,6 +931,8 @@ def user(
1105
931
 
1106
932
  # Check secondary groups, if defined
1107
933
  if groups and set(existing_user["groups"]) != set(groups):
934
+ if append:
935
+ args.append("-a")
1108
936
  args.append("-G {0}".format(",".join(groups)))
1109
937
 
1110
938
  if comment and existing_user["comment"] != comment:
@@ -1,38 +1,60 @@
1
+ import dataclasses
2
+ from typing import Any, Dict, List
3
+
1
4
  from pyinfra.api import OperationError
2
5
 
3
6
 
4
- def _create_container(**kwargs):
5
- command = []
7
+ @dataclasses.dataclass
8
+ class ContainerSpec:
9
+ image: str = ""
10
+ ports: List[str] = dataclasses.field(default_factory=list)
11
+ networks: List[str] = dataclasses.field(default_factory=list)
12
+ volumes: List[str] = dataclasses.field(default_factory=list)
13
+ env_vars: List[str] = dataclasses.field(default_factory=list)
14
+ pull_always: bool = False
15
+
16
+ def container_create_args(self):
17
+ args = []
18
+ for network in self.networks:
19
+ args.append("--network {0}".format(network))
6
20
 
7
- networks = kwargs["networks"] if kwargs["networks"] else []
8
- ports = kwargs["ports"] if kwargs["ports"] else []
9
- volumes = kwargs["volumes"] if kwargs["volumes"] else []
10
- env_vars = kwargs["env_vars"] if kwargs["env_vars"] else []
21
+ for port in self.ports:
22
+ args.append("-p {0}".format(port))
11
23
 
12
- if kwargs["image"] == "":
13
- raise OperationError("missing 1 required argument: 'image'")
24
+ for volume in self.volumes:
25
+ args.append("-v {0}".format(volume))
14
26
 
15
- command.append("docker container create --name {0}".format(kwargs["container"]))
27
+ for env_var in self.env_vars:
28
+ args.append("-e {0}".format(env_var))
16
29
 
17
- for network in networks:
18
- command.append("--network {0}".format(network))
30
+ if self.pull_always:
31
+ args.append("--pull always")
19
32
 
20
- for port in ports:
21
- command.append("-p {0}".format(port))
33
+ args.append(self.image)
22
34
 
23
- for volume in volumes:
24
- command.append("-v {0}".format(volume))
35
+ return args
25
36
 
26
- for env_var in env_vars:
27
- command.append("-e {0}".format(env_var))
37
+ def diff_from_inspect(self, inspect_dict: Dict[str, Any]) -> List[str]:
38
+ # TODO(@minor-fixes): Diff output of "docker inspect" against this spec
39
+ # to determine if the container needs to be recreated. Currently, this
40
+ # function will never recreate when attributes change, which is
41
+ # consistent with prior behavior.
42
+ del inspect_dict
43
+ return []
44
+
45
+
46
+ def _create_container(**kwargs):
47
+ if "spec" not in kwargs:
48
+ raise OperationError("missing 1 required argument: 'spec'")
28
49
 
29
- if kwargs["pull_always"]:
30
- command.append("--pull always")
50
+ spec = kwargs["spec"]
31
51
 
32
- command.append(kwargs["image"])
52
+ if not spec.image:
53
+ raise OperationError("Docker image not specified")
33
54
 
34
- if kwargs["start"]:
35
- command.append("; {0}".format(_start_container(container=kwargs["container"])))
55
+ command = [
56
+ "docker container create --name {0}".format(kwargs["container"])
57
+ ] + spec.container_create_args()
36
58
 
37
59
  return " ".join(command)
38
60