pyinfra 3.1.1__py2.py3-none-any.whl → 3.2__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 (45) hide show
  1. pyinfra/api/arguments.py +9 -2
  2. pyinfra/api/deploy.py +4 -2
  3. pyinfra/api/host.py +5 -3
  4. pyinfra/connectors/docker.py +17 -6
  5. pyinfra/connectors/sshuserclient/client.py +26 -14
  6. pyinfra/facts/apk.py +3 -1
  7. pyinfra/facts/apt.py +60 -0
  8. pyinfra/facts/crontab.py +190 -0
  9. pyinfra/facts/docker.py +6 -0
  10. pyinfra/facts/efibootmgr.py +108 -0
  11. pyinfra/facts/files.py +93 -6
  12. pyinfra/facts/git.py +3 -2
  13. pyinfra/facts/mysql.py +1 -2
  14. pyinfra/facts/opkg.py +233 -0
  15. pyinfra/facts/pipx.py +74 -0
  16. pyinfra/facts/podman.py +47 -0
  17. pyinfra/facts/postgres.py +2 -0
  18. pyinfra/facts/server.py +39 -77
  19. pyinfra/facts/util/units.py +30 -0
  20. pyinfra/facts/zfs.py +22 -19
  21. pyinfra/local.py +3 -2
  22. pyinfra/operations/apt.py +27 -20
  23. pyinfra/operations/crontab.py +189 -0
  24. pyinfra/operations/docker.py +13 -12
  25. pyinfra/operations/files.py +18 -0
  26. pyinfra/operations/git.py +23 -7
  27. pyinfra/operations/opkg.py +88 -0
  28. pyinfra/operations/pip.py +3 -2
  29. pyinfra/operations/pipx.py +90 -0
  30. pyinfra/operations/postgres.py +15 -11
  31. pyinfra/operations/runit.py +2 -0
  32. pyinfra/operations/server.py +3 -177
  33. pyinfra/operations/zfs.py +3 -3
  34. {pyinfra-3.1.1.dist-info → pyinfra-3.2.dist-info}/METADATA +11 -12
  35. {pyinfra-3.1.1.dist-info → pyinfra-3.2.dist-info}/RECORD +45 -36
  36. pyinfra_cli/inventory.py +26 -9
  37. pyinfra_cli/prints.py +18 -3
  38. pyinfra_cli/util.py +3 -0
  39. tests/test_cli/test_cli_deploy.py +15 -13
  40. tests/test_cli/test_cli_inventory.py +53 -0
  41. tests/test_connectors/test_sshuserclient.py +68 -1
  42. {pyinfra-3.1.1.dist-info → pyinfra-3.2.dist-info}/LICENSE.md +0 -0
  43. {pyinfra-3.1.1.dist-info → pyinfra-3.2.dist-info}/WHEEL +0 -0
  44. {pyinfra-3.1.1.dist-info → pyinfra-3.2.dist-info}/entry_points.txt +0 -0
  45. {pyinfra-3.1.1.dist-info → pyinfra-3.2.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,30 @@
1
+ # from https://stackoverflow.com/a/60708339, but with a few modifications
2
+ from __future__ import annotations # for | in type hints
3
+
4
+ import re
5
+
6
+ units = {
7
+ "B": 1,
8
+ "KB": 10**3,
9
+ "MB": 10**6,
10
+ "GB": 10**9,
11
+ "TB": 10**12,
12
+ "KIB": 2**10,
13
+ "MIB": 2**20,
14
+ "GIB": 2**30,
15
+ "TIB": 2**40,
16
+ }
17
+
18
+
19
+ def parse_human_readable_size(size: str) -> int:
20
+ size = size.upper()
21
+ if not re.match(r" ", size):
22
+ size = re.sub(r"([KMGT]?I?[B])", r" \1", size)
23
+ number, unit = [string.strip() for string in size.split()]
24
+ return int(float(number) * units[unit])
25
+
26
+
27
+ def parse_size(size: str | int) -> int:
28
+ if isinstance(size, int):
29
+ return size
30
+ return parse_human_readable_size(size)
pyinfra/facts/zfs.py CHANGED
@@ -1,5 +1,5 @@
1
1
  """
2
- Manage ZFS filesystems.
2
+ Gather information about ZFS filesystems.
3
3
  """
4
4
 
5
5
  from pyinfra.api import FactBase, ShortFactBase
@@ -15,43 +15,46 @@ def _process_zfs_props_table(output):
15
15
  return datasets
16
16
 
17
17
 
18
- class Pools(FactBase):
18
+ class ZfsPools(FactBase):
19
19
  def command(self):
20
20
  return "zpool get -H all"
21
21
 
22
- @staticmethod
23
- def process(output):
22
+ def process(self, output):
24
23
  return _process_zfs_props_table(output)
25
24
 
26
25
 
27
- class Datasets(FactBase):
26
+ class ZfsDatasets(FactBase):
28
27
  def command(self):
29
28
  return "zfs get -H all"
30
29
 
31
- @staticmethod
32
- def process(output):
30
+ def process(self, output):
33
31
  return _process_zfs_props_table(output)
34
32
 
35
33
 
36
- class Filesystems(ShortFactBase):
37
- fact = Datasets
34
+ class ZfsFilesystems(ShortFactBase):
35
+ fact = ZfsDatasets
38
36
 
39
- @staticmethod
40
- def process_data(data):
37
+ def process_data(self, data):
41
38
  return {name: props for name, props in data.items() if props.get("type") == "filesystem"}
42
39
 
43
40
 
44
- class Snapshots(ShortFactBase):
45
- fact = Datasets
41
+ class ZfsSnapshots(ShortFactBase):
42
+ fact = ZfsDatasets
46
43
 
47
- @staticmethod
48
- def process_data(data):
44
+ def process_data(self, data):
49
45
  return {name: props for name, props in data.items() if props.get("type") == "snapshot"}
50
46
 
51
47
 
52
- class Volumes(ShortFactBase):
53
- fact = Datasets
48
+ class ZfsVolumes(ShortFactBase):
49
+ fact = ZfsDatasets
54
50
 
55
- @staticmethod
56
- def process_data(data):
51
+ def process_data(self, data):
57
52
  return {name: props for name, props in data.items() if props.get("type") == "volume"}
53
+
54
+
55
+ # TODO: remove these in v4! Or flip the convention and remove all the other fact prefixes!
56
+ Pools = ZfsPools
57
+ Datasets = ZfsDatasets
58
+ Filesystems = ZfsFilesystems
59
+ Snapshots = ZfsSnapshots
60
+ Volumes = ZfsVolumes
pyinfra/local.py CHANGED
@@ -1,4 +1,5 @@
1
1
  from os import path
2
+ from typing import Optional
2
3
 
3
4
  import click
4
5
 
@@ -10,7 +11,7 @@ from pyinfra.connectors.util import run_local_process
10
11
  from pyinfra.context import ctx_state
11
12
 
12
13
 
13
- def include(filename: str):
14
+ def include(filename: str, data: Optional[dict] = None):
14
15
  """
15
16
  Executes a local python file within the ``pyinfra.state.cwd``
16
17
  directory.
@@ -33,7 +34,7 @@ def include(filename: str):
33
34
 
34
35
  from pyinfra_cli.util import exec_file
35
36
 
36
- with host.deploy(path.relpath(filename, state.cwd), None, None, in_deploy=False):
37
+ with host.deploy(path.relpath(filename, state.cwd), None, data, in_deploy=False):
37
38
  exec_file(filename)
38
39
 
39
40
  # One potential solution to the above is to add local as an actual
pyinfra/operations/apt.py CHANGED
@@ -9,7 +9,13 @@ from urllib.parse import urlparse
9
9
 
10
10
  from pyinfra import host
11
11
  from pyinfra.api import OperationError, operation
12
- from pyinfra.facts.apt import AptKeys, AptSources, parse_apt_repo
12
+ from pyinfra.facts.apt import (
13
+ AptKeys,
14
+ AptSources,
15
+ SimulateOperationWillChange,
16
+ noninteractive_apt,
17
+ parse_apt_repo,
18
+ )
13
19
  from pyinfra.facts.deb import DebPackage, DebPackages
14
20
  from pyinfra.facts.files import File
15
21
  from pyinfra.facts.gpg import GpgKey
@@ -21,21 +27,22 @@ from .util.packaging import ensure_packages
21
27
  APT_UPDATE_FILENAME = "/var/lib/apt/periodic/update-success-stamp"
22
28
 
23
29
 
24
- def noninteractive_apt(command: str, force=False):
25
- args = ["DEBIAN_FRONTEND=noninteractive apt-get -y"]
26
-
27
- if force:
28
- args.append("--force-yes")
29
-
30
- args.extend(
31
- (
32
- '-o Dpkg::Options::="--force-confdef"',
33
- '-o Dpkg::Options::="--force-confold"',
34
- command,
35
- ),
36
- )
37
-
38
- return " ".join(args)
30
+ def _simulate_then_perform(command: str):
31
+ changes = host.get_fact(SimulateOperationWillChange, command)
32
+
33
+ if not changes:
34
+ # Simulating apt-get command failed, so the actual
35
+ # operation will probably fail too:
36
+ yield noninteractive_apt(command)
37
+ elif (
38
+ changes["upgraded"] == 0
39
+ and changes["newly_installed"] == 0
40
+ and changes["removed"] == 0
41
+ and changes["not_upgraded"] == 0
42
+ ):
43
+ host.noop(f"{command} skipped, no changes would be performed")
44
+ else:
45
+ yield noninteractive_apt(command)
39
46
 
40
47
 
41
48
  @operation()
@@ -321,7 +328,7 @@ def update(cache_time: int | None = None):
321
328
  _update = update # noqa: E305
322
329
 
323
330
 
324
- @operation(is_idempotent=False)
331
+ @operation()
325
332
  def upgrade(auto_remove: bool = False):
326
333
  """
327
334
  Upgrades all apt packages.
@@ -349,13 +356,13 @@ def upgrade(auto_remove: bool = False):
349
356
  if auto_remove:
350
357
  command.append("--autoremove")
351
358
 
352
- yield noninteractive_apt(" ".join(command))
359
+ yield from _simulate_then_perform(" ".join(command))
353
360
 
354
361
 
355
362
  _upgrade = upgrade # noqa: E305 (for use below where update is a kwarg)
356
363
 
357
364
 
358
- @operation(is_idempotent=False)
365
+ @operation()
359
366
  def dist_upgrade():
360
367
  """
361
368
  Updates all apt packages, employing dist-upgrade.
@@ -369,7 +376,7 @@ def dist_upgrade():
369
376
  )
370
377
  """
371
378
 
372
- yield noninteractive_apt("dist-upgrade")
379
+ yield from _simulate_then_perform("dist-upgrade")
373
380
 
374
381
 
375
382
  @operation()
@@ -0,0 +1,189 @@
1
+ from __future__ import annotations
2
+
3
+ import shlex
4
+
5
+ from pyinfra import host
6
+ from pyinfra.api import StringCommand, operation
7
+ from pyinfra.api.util import try_int
8
+ from pyinfra.facts.crontab import Crontab, CrontabFile
9
+ from pyinfra.operations.util.files import sed_replace
10
+
11
+
12
+ @operation()
13
+ def crontab(
14
+ command: str,
15
+ present=True,
16
+ user: str | None = None,
17
+ cron_name: str | None = None,
18
+ minute="*",
19
+ hour="*",
20
+ month="*",
21
+ day_of_week="*",
22
+ day_of_month="*",
23
+ special_time: str | None = None,
24
+ interpolate_variables=False,
25
+ ):
26
+ """
27
+ Add/remove/update crontab entries.
28
+
29
+ + command: the command for the cron
30
+ + present: whether this cron command should exist
31
+ + user: the user whose crontab to manage
32
+ + cron_name: name the cronjob so future changes to the command will overwrite
33
+ + modify_cron_name: modify the cron name
34
+ + minute: which minutes to execute the cron
35
+ + hour: which hours to execute the cron
36
+ + month: which months to execute the cron
37
+ + day_of_week: which day of the week to execute the cron
38
+ + day_of_month: which day of the month to execute the cron
39
+ + special_time: cron "nickname" time (@reboot, @daily, etc), overrides others
40
+ + interpolate_variables: whether to interpolate variables in ``command``
41
+
42
+ Cron commands:
43
+ Unless ``name`` is specified the command is used to identify crontab entries.
44
+ This means commands must be unique within a given users crontab. If you require
45
+ multiple identical commands, provide a different name argument for each.
46
+
47
+ Special times:
48
+ When provided, ``special_time`` will be used instead of any values passed in
49
+ for ``minute``/``hour``/``month``/``day_of_week``/``day_of_month``.
50
+
51
+ **Example:**
52
+
53
+ .. code:: python
54
+
55
+ # simple example for a crontab
56
+ crontab.crontab(
57
+ name="Backup /etc weekly",
58
+ command="/bin/tar cf /tmp/etc_bup.tar /etc",
59
+ name="backup_etc",
60
+ day_of_week=0,
61
+ hour=1,
62
+ minute=0,
63
+ )
64
+ """
65
+
66
+ def comma_sep(value):
67
+ if isinstance(value, (list, tuple)):
68
+ return ",".join("{0}".format(v) for v in value)
69
+ return value
70
+
71
+ minute = comma_sep(minute)
72
+ hour = comma_sep(hour)
73
+ month = comma_sep(month)
74
+ day_of_week = comma_sep(day_of_week)
75
+ day_of_month = comma_sep(day_of_month)
76
+
77
+ ctb0: CrontabFile | dict = host.get_fact(Crontab, user=user)
78
+ # facts from test are in dict
79
+ if isinstance(ctb0, dict):
80
+ ctb = CrontabFile(ctb0)
81
+ else:
82
+ ctb = ctb0
83
+ name_comment = "# pyinfra-name={0}".format(cron_name)
84
+
85
+ existing_crontab = ctb.get_command(command=command, name=cron_name)
86
+ existing_crontab_command = existing_crontab["command"] if existing_crontab else command
87
+ existing_crontab_match = existing_crontab["command"] if existing_crontab else command
88
+
89
+ exists = existing_crontab is not None
90
+ exists_name = existing_crontab is not None and name_comment in existing_crontab.get(
91
+ "comments", ""
92
+ )
93
+
94
+ edit_commands: list[str | StringCommand] = []
95
+ temp_filename = host.get_temp_filename()
96
+
97
+ if special_time:
98
+ new_crontab_line = "{0} {1}".format(special_time, command)
99
+ else:
100
+ new_crontab_line = "{minute} {hour} {day_of_month} {month} {day_of_week} {command}".format(
101
+ minute=minute,
102
+ hour=hour,
103
+ day_of_month=day_of_month,
104
+ month=month,
105
+ day_of_week=day_of_week,
106
+ command=command,
107
+ )
108
+
109
+ existing_crontab_match = ".*{0}.*".format(existing_crontab_match)
110
+
111
+ # Don't want the cron and it does exist? Remove the line
112
+ if not present and exists:
113
+ edit_commands.append(
114
+ sed_replace(
115
+ temp_filename,
116
+ existing_crontab_match,
117
+ "",
118
+ interpolate_variables=interpolate_variables,
119
+ ),
120
+ )
121
+
122
+ # Want the cron but it doesn't exist? Append the line
123
+ elif present and not exists:
124
+ print("present", present, "exists", exists)
125
+ if ctb: # append a blank line if cron entries already exist
126
+ edit_commands.append("echo '' >> {0}".format(temp_filename))
127
+ if cron_name:
128
+ edit_commands.append(
129
+ "echo {0} >> {1}".format(
130
+ shlex.quote(name_comment),
131
+ temp_filename,
132
+ ),
133
+ )
134
+
135
+ edit_commands.append(
136
+ "echo {0} >> {1}".format(
137
+ shlex.quote(new_crontab_line),
138
+ temp_filename,
139
+ ),
140
+ )
141
+
142
+ # We have the cron and it exists, do it's details? If not, replace the line
143
+ elif present and exists:
144
+ assert existing_crontab is not None
145
+ if any(
146
+ (
147
+ exists_name != (cron_name is not None),
148
+ special_time != existing_crontab.get("special_time"),
149
+ try_int(minute) != existing_crontab.get("minute"),
150
+ try_int(hour) != existing_crontab.get("hour"),
151
+ try_int(month) != existing_crontab.get("month"),
152
+ try_int(day_of_week) != existing_crontab.get("day_of_week"),
153
+ try_int(day_of_month) != existing_crontab.get("day_of_month"),
154
+ existing_crontab_command != command,
155
+ ),
156
+ ):
157
+ if not exists_name and cron_name:
158
+ new_crontab_line = f"{name_comment}\n{new_crontab_line}"
159
+ edit_commands.append(
160
+ sed_replace(
161
+ temp_filename,
162
+ existing_crontab_match,
163
+ new_crontab_line,
164
+ interpolate_variables=interpolate_variables,
165
+ ),
166
+ )
167
+
168
+ if edit_commands:
169
+ crontab_args = []
170
+ if user:
171
+ crontab_args.append("-u {0}".format(user))
172
+
173
+ # List the crontab into a temporary file if it exists
174
+ if ctb:
175
+ yield "crontab -l {0} > {1}".format(" ".join(crontab_args), temp_filename)
176
+
177
+ # Now yield any edits
178
+ for edit_command in edit_commands:
179
+ yield edit_command
180
+
181
+ # Finally, use the tempfile to write a new crontab
182
+ yield "crontab {0} {1}".format(" ".join(crontab_args), temp_filename)
183
+ else:
184
+ host.noop(
185
+ "crontab {0} {1}".format(
186
+ command,
187
+ "exists" if present else "does not exist",
188
+ ),
189
+ )
@@ -1,5 +1,7 @@
1
1
  """
2
- Manager Docker Containers, Volumes and Networks
2
+ Manager Docker containers, volumes and networks. These operations allow you to manage Docker from
3
+ the view of the current inventory host. See the :doc:`../connectors/docker` to use Docker containers
4
+ as inventory directly.
3
5
  """
4
6
 
5
7
  from pyinfra import host
@@ -30,9 +32,9 @@ def container(
30
32
  + networks: network list to attach on container
31
33
  + ports: port list to expose
32
34
  + volumes: volume list to map on container
33
- + env_vars: environment varible list to inject on container
35
+ + env_vars: environment variable list to inject on container
34
36
  + pull_always: force image pull
35
- + force: remove a contaner with same name and create a new one
37
+ + force: remove a container with same name and create a new one
36
38
  + present: whether the container should be up and running
37
39
  + start: start or stop the container
38
40
 
@@ -125,7 +127,7 @@ def image(image, present=True):
125
127
  Manage Docker images
126
128
 
127
129
  + image: Image and tag ex: nginx:alpine
128
- + present: whether the Docker image should be exist
130
+ + present: whether the Docker image should exist
129
131
 
130
132
  **Examples:**
131
133
 
@@ -188,7 +190,7 @@ def volume(volume, driver="", labels=None, present=True):
188
190
  if present:
189
191
 
190
192
  if existent_volume:
191
- host.noop("Volume alredy exist!")
193
+ host.noop("Volume already exists!")
192
194
  return
193
195
 
194
196
  yield handle_docker(
@@ -231,8 +233,8 @@ def network(
231
233
  """
232
234
  Manage docker networks
233
235
 
234
- + network_name: Image name
235
- + driver: Container image and tag ex: nginx:alpine
236
+ + network: Network name
237
+ + driver: Network driver ex: bridge or overlay
236
238
  + gateway: IPv4 or IPv6 Gateway for the master subnet
237
239
  + ip_range: Allocate container ip from a sub-range
238
240
  + ipam_driver: IP Address Management Driver
@@ -251,8 +253,7 @@ def network(
251
253
 
252
254
  # Create Docker network
253
255
  docker.network(
254
- name="Create nginx network",
255
- network_name="nginx",
256
+ network="nginx",
256
257
  attachable=True,
257
258
  present=True,
258
259
  )
@@ -261,7 +262,7 @@ def network(
261
262
 
262
263
  if present:
263
264
  if existent_network:
264
- host.noop("Alredy exist a network with {0} name!".format(network))
265
+ host.noop("Network {0} already exists!".format(network))
265
266
  return
266
267
 
267
268
  yield handle_docker(
@@ -284,12 +285,12 @@ def network(
284
285
 
285
286
  else:
286
287
  if existent_network is None:
287
- host.noop("Ther is not network with {0} name!".format(network))
288
+ host.noop("Network {0} does not exist!".format(network))
288
289
  return
289
290
 
290
291
  yield handle_docker(
291
292
  resource="network",
292
- command="create",
293
+ command="remove",
293
294
  network=network,
294
295
  )
295
296
 
@@ -51,6 +51,7 @@ from pyinfra.facts.files import (
51
51
  Md5File,
52
52
  Sha1File,
53
53
  Sha256File,
54
+ Sha384File,
54
55
  )
55
56
  from pyinfra.facts.server import Date, Which
56
57
 
@@ -67,6 +68,7 @@ def download(
67
68
  mode: str | None = None,
68
69
  cache_time: int | None = None,
69
70
  force=False,
71
+ sha384sum: str | None = None,
70
72
  sha256sum: str | None = None,
71
73
  sha1sum: str | None = None,
72
74
  md5sum: str | None = None,
@@ -84,6 +86,7 @@ def download(
84
86
  + mode: permissions of the files
85
87
  + cache_time: if the file exists already, re-download after this time (in seconds)
86
88
  + force: always download the file, even if it already exists
89
+ + sha384sum: sha384 hash to checksum the downloaded file against
87
90
  + sha256sum: sha256 hash to checksum the downloaded file against
88
91
  + sha1sum: sha1 hash to checksum the downloaded file against
89
92
  + md5sum: md5 hash to checksum the downloaded file against
@@ -135,6 +138,10 @@ def download(
135
138
  if sha256sum != host.get_fact(Sha256File, path=dest):
136
139
  download = True
137
140
 
141
+ if sha384sum:
142
+ if sha384sum != host.get_fact(Sha384File, path=dest):
143
+ download = True
144
+
138
145
  if md5sum:
139
146
  if md5sum != host.get_fact(Md5File, path=dest):
140
147
  download = True
@@ -211,6 +218,17 @@ def download(
211
218
  QuoteString("SHA256 did not match!"),
212
219
  )
213
220
 
221
+ if sha384sum:
222
+ yield make_formatted_string_command(
223
+ (
224
+ "(( sha384sum {0} 2> /dev/null || shasum -a 384 {0} ) "
225
+ "| grep {1}) || ( echo {2} && exit 1 )"
226
+ ),
227
+ QuoteString(dest),
228
+ sha384sum,
229
+ QuoteString("SHA384 did not match!"),
230
+ )
231
+
214
232
  if md5sum:
215
233
  yield make_formatted_string_command(
216
234
  (
pyinfra/operations/git.py CHANGED
@@ -16,24 +16,40 @@ from .util.files import chown, unix_path_join
16
16
 
17
17
 
18
18
  @operation()
19
- def config(key: str, value: str, multi_value=False, repo: str | None = None):
19
+ def config(key: str, value: str, multi_value=False, repo: str | None = None, system=False):
20
20
  """
21
- Manage git config for a repository or globally.
21
+ Manage git config at repository, user or system level.
22
22
 
23
23
  + key: the key of the config to ensure
24
24
  + value: the value this key should have
25
25
  + multi_value: Add the value rather than set it for settings that can have multiple values
26
26
  + repo: specify the git repo path to edit local config (defaults to global)
27
+ + system: whether, when ``repo`` is unspecified, to work at system level (or default to global)
27
28
 
28
- **Example:**
29
+ **Examples:**
29
30
 
30
31
  .. code:: python
31
32
 
32
33
  git.config(
33
- name="Ensure user name is set for a repo",
34
+ name="Always prune specified repo",
35
+ key="fetch.prune",
36
+ value="true",
37
+ repo="/usr/local/src/pyinfra",
38
+ )
39
+
40
+ git.config(
41
+ name="Ensure user name is set for all repos of specified user",
34
42
  key="user.name",
35
43
  value="Anon E. Mouse",
36
- repo="/usr/local/src/pyinfra",
44
+ _sudo=True,
45
+ _sudo_user="anon"
46
+ )
47
+
48
+ git.config(
49
+ name="Ensure same date format for all users",
50
+ key="log.date",
51
+ value="iso",
52
+ system=True
37
53
  )
38
54
 
39
55
  """
@@ -41,14 +57,14 @@ def config(key: str, value: str, multi_value=False, repo: str | None = None):
41
57
  existing_config = {}
42
58
 
43
59
  if not repo:
44
- existing_config = host.get_fact(GitConfig)
60
+ existing_config = host.get_fact(GitConfig, system=system)
45
61
 
46
62
  # Only get the config if the repo exists at this stage
47
63
  elif host.get_fact(Directory, path=unix_path_join(repo, ".git")):
48
64
  existing_config = host.get_fact(GitConfig, repo=repo)
49
65
 
50
66
  if repo is None:
51
- base_command = "git config --global"
67
+ base_command = "git config" + (" --system" if system else " --global")
52
68
  else:
53
69
  base_command = "cd {0} && git config --local".format(repo)
54
70
 
@@ -0,0 +1,88 @@
1
+ """
2
+ Manage packages on OpenWrt using opkg
3
+ + ``update`` - update local copy of package information
4
+ + ``packages`` - install and remove packages
5
+
6
+ see https://openwrt.org/docs/guide-user/additional-software/opkg
7
+ OpenWrt recommends against upgrading all packages thus there is no ``opkg.upgrade`` function
8
+ """
9
+
10
+ from typing import List, Union
11
+
12
+ from pyinfra import host
13
+ from pyinfra.api import StringCommand, operation
14
+ from pyinfra.facts.opkg import OpkgPackages
15
+ from pyinfra.operations.util.packaging import ensure_packages
16
+
17
+ EQUALS = "="
18
+
19
+
20
+ @operation(is_idempotent=False)
21
+ def update():
22
+ """
23
+ Update the local opkg information.
24
+ """
25
+
26
+ yield StringCommand("opkg update")
27
+
28
+
29
+ _update = update
30
+
31
+
32
+ @operation()
33
+ def packages(
34
+ packages: Union[str, List[str]] = "",
35
+ present: bool = True,
36
+ latest: bool = False,
37
+ update: bool = True,
38
+ ):
39
+ """
40
+ Add/remove/update opkg packages.
41
+
42
+ + packages: package or list of packages to that must/must not be present
43
+ + present: whether the package(s) should be installed (default True) or removed
44
+ + latest: whether to attempt to upgrade the specified package(s) (default False)
45
+ + update: run ``opkg update`` before installing packages (default True)
46
+
47
+ Not Supported:
48
+ Opkg does not support version pinning, i.e. ``<pkg>=<version>`` is not allowed
49
+ and will cause an exception.
50
+
51
+ **Examples:**
52
+
53
+ .. code:: python
54
+
55
+ # Ensure packages are installed∂ (will not force package upgrade)
56
+ opkg.packages(['asterisk', 'vim'], name="Install Asterisk and Vim")
57
+
58
+ # Install the latest versions of packages (always check)
59
+ opkg.packages(
60
+ 'vim',
61
+ latest=True,
62
+ name="Ensure we have the latest version of Vim"
63
+ )
64
+ """
65
+ if str(packages) == "" or (
66
+ isinstance(packages, list) and (len(packages) < 1 or all(len(p) < 1 for p in packages))
67
+ ):
68
+ host.noop("empty or invalid package list provided to opkg.packages")
69
+ return
70
+
71
+ pkg_list = packages if isinstance(packages, list) else [packages]
72
+ have_equals = ",".join([pkg.split(EQUALS)[0] for pkg in pkg_list if EQUALS in pkg])
73
+ if len(have_equals) > 0:
74
+ raise ValueError(f"opkg does not support version pinning but found for: '{have_equals}'")
75
+
76
+ if update:
77
+ yield from _update._inner()
78
+
79
+ yield from ensure_packages(
80
+ host,
81
+ pkg_list,
82
+ host.get_fact(OpkgPackages),
83
+ present,
84
+ install_command="opkg install",
85
+ upgrade_command="opkg upgrade",
86
+ uninstall_command="opkg remove",
87
+ latest=latest,
88
+ )