pyinfra 3.3.1__py2.py3-none-any.whl → 3.4.1__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 -16
  2. pyinfra/api/deploy.py +1 -1
  3. pyinfra/api/facts.py +10 -26
  4. pyinfra/api/host.py +10 -4
  5. pyinfra/api/inventory.py +5 -2
  6. pyinfra/api/operation.py +1 -1
  7. pyinfra/api/util.py +20 -6
  8. pyinfra/connectors/docker.py +117 -38
  9. pyinfra/connectors/dockerssh.py +1 -0
  10. pyinfra/connectors/local.py +1 -0
  11. pyinfra/connectors/ssh.py +1 -0
  12. pyinfra/connectors/terraform.py +3 -0
  13. pyinfra/connectors/vagrant.py +3 -0
  14. pyinfra/context.py +14 -5
  15. pyinfra/facts/brew.py +1 -0
  16. pyinfra/facts/docker.py +6 -2
  17. pyinfra/facts/git.py +10 -0
  18. pyinfra/facts/hardware.py +1 -1
  19. pyinfra/facts/opkg.py +1 -0
  20. pyinfra/facts/server.py +81 -23
  21. pyinfra/facts/systemd.py +1 -1
  22. pyinfra/operations/crontab.py +7 -5
  23. pyinfra/operations/docker.py +2 -0
  24. pyinfra/operations/files.py +64 -21
  25. pyinfra/operations/flatpak.py +17 -2
  26. pyinfra/operations/git.py +6 -2
  27. pyinfra/operations/server.py +34 -24
  28. pyinfra/operations/util/docker.py +4 -0
  29. pyinfra/operations/util/files.py +44 -3
  30. {pyinfra-3.3.1.dist-info → pyinfra-3.4.1.dist-info}/METADATA +5 -4
  31. {pyinfra-3.3.1.dist-info → pyinfra-3.4.1.dist-info}/RECORD +45 -45
  32. {pyinfra-3.3.1.dist-info → pyinfra-3.4.1.dist-info}/entry_points.txt +1 -0
  33. pyinfra_cli/inventory.py +1 -1
  34. pyinfra_cli/main.py +4 -2
  35. tests/test_api/test_api_arguments.py +25 -20
  36. tests/test_api/test_api_facts.py +28 -15
  37. tests/test_api/test_api_operations.py +43 -44
  38. tests/test_cli/test_cli.py +17 -17
  39. tests/test_cli/test_cli_inventory.py +4 -4
  40. tests/test_cli/test_context_objects.py +26 -26
  41. tests/test_connectors/test_docker.py +83 -43
  42. tests/test_connectors/test_ssh.py +153 -132
  43. {pyinfra-3.3.1.dist-info → pyinfra-3.4.1.dist-info}/LICENSE.md +0 -0
  44. {pyinfra-3.3.1.dist-info → pyinfra-3.4.1.dist-info}/WHEEL +0 -0
  45. {pyinfra-3.3.1.dist-info → pyinfra-3.4.1.dist-info}/top_level.txt +0 -0
pyinfra/facts/git.py CHANGED
@@ -23,6 +23,16 @@ class GitBranch(GitFactBase):
23
23
  return re.sub(r"(heads|tags)/", r"", "\n".join(output))
24
24
 
25
25
 
26
+ class GitTag(GitFactBase):
27
+ @override
28
+ def command(self, repo) -> str:
29
+ return "! test -d {0} || (cd {0} && git tag)".format(repo)
30
+
31
+ @override
32
+ def process(self, output):
33
+ return output
34
+
35
+
26
36
  class GitConfig(GitFactBase):
27
37
  default = dict
28
38
 
pyinfra/facts/hardware.py CHANGED
@@ -46,7 +46,7 @@ class Memory(FactBase):
46
46
  value, key = line.split(" ", 1)
47
47
 
48
48
  try:
49
- value = int(value)
49
+ value = float(value)
50
50
  except ValueError:
51
51
  continue
52
52
 
pyinfra/facts/opkg.py CHANGED
@@ -81,6 +81,7 @@ class OpkgConf(FactBase):
81
81
  re.X,
82
82
  )
83
83
 
84
+ @override
84
85
  @staticmethod
85
86
  def default():
86
87
  return OpkgConfInfo({}, "", {}, {})
pyinfra/facts/server.py CHANGED
@@ -1,16 +1,18 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import json
3
4
  import os
4
5
  import re
5
6
  import shutil
6
7
  from datetime import datetime
7
8
  from tempfile import mkdtemp
8
- from typing import Dict, List, Optional
9
+ from typing import Dict, Iterable, List, Optional, Tuple
9
10
 
10
11
  from dateutil.parser import parse as parse_date
11
12
  from distro import distro
12
13
  from typing_extensions import TypedDict, override
13
14
 
15
+ from pyinfra import host
14
16
  from pyinfra.api import FactBase, ShortFactBase
15
17
  from pyinfra.api.util import try_int
16
18
  from pyinfra.facts import crontab
@@ -205,40 +207,96 @@ class Mounts(FactBase[Dict[str, MountsDict]]):
205
207
  default = dict
206
208
 
207
209
  @override
208
- def command(self):
209
- return "mount"
210
+ def command(self) -> str:
211
+ self._kernel = host.get_fact(Kernel)
212
+
213
+ if self._kernel.strip() == "FreeBSD":
214
+ return "mount -p --libxo json"
215
+ else:
216
+ return "cat /proc/self/mountinfo"
210
217
 
211
218
  @override
212
219
  def process(self, output) -> dict[str, MountsDict]:
213
220
  devices: dict[str, MountsDict] = {}
214
221
 
215
- for line in output:
216
- is_map = False
217
- if line.startswith("map "):
218
- line = line[4:]
219
- is_map = True
222
+ def unescape_octal(match: re.Match) -> str:
223
+ s = match.group(0)[1:] # skip the backslash
224
+ return chr(int(s, base=8))
220
225
 
221
- device, _, path, other_bits = line.split(" ", 3)
226
+ def replace_octal(s: str) -> str:
227
+ """
228
+ Unescape strings encoded by linux's string_escape_mem with ESCAPE_OCTAL flag.
229
+ """
230
+ return re.sub(r"\\[0-7]{3}", unescape_octal, s)
222
231
 
223
- if is_map:
224
- device = "map {0}".format(device)
232
+ if self._kernel == "FreeBSD":
233
+ full_output = "\n".join(output)
234
+ json_output = json.loads(full_output)
235
+ mount_fstab = json_output["mount"]["fstab"]
225
236
 
226
- if other_bits.startswith("type"):
227
- _, type_, options = other_bits.split(" ", 2)
228
- options = options.strip("()").split(",")
229
- else:
230
- options = other_bits.strip("()").split(",")
231
- type_ = options.pop(0)
237
+ for entry in mount_fstab:
238
+ path = entry["mntpoint"]
239
+ type_ = entry["fstype"]
240
+ device = entry["device"]
241
+ options = [option.strip() for option in entry["opts"].split(",")]
232
242
 
233
- devices[path] = {
234
- "device": device,
235
- "type": type_,
236
- "options": [option.strip() for option in options],
243
+ devices[path] = {"device": device, "type": type_, "options": options}
244
+
245
+ return devices
246
+
247
+ for line in output:
248
+ # ignore mount ID, parent ID, major:minor, root
249
+ _, _, _, _, mount_point, mount_options, line = line.split(sep=" ", maxsplit=6)
250
+
251
+ # ignore optional tags "shared", "master", "propagate_from" and "unbindable"
252
+ while True:
253
+ optional, line = line.split(sep=" ", maxsplit=1)
254
+ if optional == "-":
255
+ break
256
+
257
+ fs_type, mount_source, super_options = line.split(sep=" ")
258
+
259
+ mount_options = mount_options.split(sep=",")
260
+
261
+ # escaped: mount_point, mount_source, super_options
262
+ # these strings can contain characters encoded in octal, e.g. '\054' for ','
263
+ mount_point = replace_octal(mount_point)
264
+ mount_source = replace_octal(mount_source)
265
+
266
+ # mount_options will override ro/rw and can be different than the super block options
267
+ # filter them, so they don't appear twice
268
+ super_options = [
269
+ replace_octal(opt)
270
+ for opt in super_options.split(sep=",")
271
+ if opt not in ["ro", "rw"]
272
+ ]
273
+
274
+ devices[mount_point] = {
275
+ "device": mount_source,
276
+ "type": fs_type,
277
+ "options": mount_options + super_options,
237
278
  }
238
279
 
239
280
  return devices
240
281
 
241
282
 
283
+ class Port(FactBase[Tuple[str, int] | Tuple[None, None]]):
284
+ """
285
+ Returns the process occuping a port and its PID
286
+ """
287
+
288
+ @override
289
+ def command(self, port: int) -> str:
290
+ return f"ss -lptnH 'src :{port}'"
291
+
292
+ @override
293
+ def process(self, output: Iterable[str]) -> Tuple[str, int] | Tuple[None, None]:
294
+ for line in output:
295
+ proc, pid = line.split('"')[1], int(line.split("pid=")[1].split(",")[0])
296
+ return (proc, pid)
297
+ return None, None
298
+
299
+
242
300
  class KernelModules(FactBase):
243
301
  """
244
302
  Returns a dictionary of kernel module name -> info.
@@ -470,10 +528,10 @@ class Users(FactBase):
470
528
  for i in `cat /etc/passwd | cut -d: -f1`; do
471
529
  ENTRY=`grep ^$i: /etc/passwd`;
472
530
  LASTLOG=`(((lastlog -u $i || lastlogin $i) 2> /dev/null) | grep ^$i | tr -s ' ')`;
473
- PASSWORD=`grep ^$i: /etc/shadow | cut -d: -f2`;
531
+ PASSWORD=`(grep ^$i: /etc/shadow || grep ^$i: /etc/master.passwd) 2> /dev/null | cut -d: -f2`;
474
532
  echo "$ENTRY|`id -gn $i`|`id -Gn $i`|$LASTLOG|$PASSWORD";
475
533
  done
476
- """.strip()
534
+ """.strip() # noqa
477
535
 
478
536
  default = dict
479
537
 
pyinfra/facts/systemd.py CHANGED
@@ -64,7 +64,7 @@ class SystemdStatus(FactBase[Dict[str, bool]]):
64
64
  default = dict
65
65
 
66
66
  state_key = "SubState"
67
- state_values = ["running", "waiting", "exited", "listening"]
67
+ state_values = ["running", "waiting", "exited", "listening", "mounted"]
68
68
 
69
69
  @override
70
70
  def command(
@@ -2,11 +2,13 @@ from __future__ import annotations
2
2
 
3
3
  import shlex
4
4
 
5
- from pyinfra import host
6
- from pyinfra.api import StringCommand, operation
5
+ from pyinfra import logger
6
+ from pyinfra.api.command import StringCommand
7
+ from pyinfra.api.operation import operation
7
8
  from pyinfra.api.util import try_int
9
+ from pyinfra.context import host
8
10
  from pyinfra.facts.crontab import Crontab, CrontabFile
9
- from pyinfra.operations.util.files import sed_replace
11
+ from pyinfra.operations.util.files import sed_delete, sed_replace
10
12
 
11
13
 
12
14
  @operation()
@@ -111,7 +113,7 @@ def crontab(
111
113
  # Don't want the cron and it does exist? Remove the line
112
114
  if not present and exists:
113
115
  edit_commands.append(
114
- sed_replace(
116
+ sed_delete(
115
117
  temp_filename,
116
118
  existing_crontab_match,
117
119
  "",
@@ -121,7 +123,7 @@ def crontab(
121
123
 
122
124
  # Want the cron but it doesn't exist? Append the line
123
125
  elif present and not exists:
124
- print("present", present, "exists", exists)
126
+ logger.debug(f"present: {present}, exists: {exists}")
125
127
  if ctb: # append a blank line if cron entries already exist
126
128
  edit_commands.append("echo '' >> {0}".format(temp_filename))
127
129
  if cron_name:
@@ -227,6 +227,7 @@ def network(
227
227
  ipam_driver="",
228
228
  subnet="",
229
229
  scope="",
230
+ aux_addresses=None,
230
231
  opts=None,
231
232
  ipam_opts=None,
232
233
  labels=None,
@@ -279,6 +280,7 @@ def network(
279
280
  ipam_driver=ipam_driver,
280
281
  subnet=subnet,
281
282
  scope=scope,
283
+ aux_addresses=aux_addresses,
282
284
  opts=opts,
283
285
  ipam_opts=ipam_opts,
284
286
  labels=labels,
@@ -32,7 +32,9 @@ from pyinfra.api.command import make_formatted_string_command
32
32
  from pyinfra.api.util import (
33
33
  get_call_location,
34
34
  get_file_io,
35
+ get_file_md5,
35
36
  get_file_sha1,
37
+ get_file_sha256,
36
38
  get_path_permissions_mode,
37
39
  get_template,
38
40
  memoize,
@@ -56,7 +58,14 @@ from pyinfra.facts.files import (
56
58
  from pyinfra.facts.server import Date, Which
57
59
 
58
60
  from .util import files as file_utils
59
- from .util.files import adjust_regex, ensure_mode_int, get_timestamp, sed_replace, unix_path_join
61
+ from .util.files import (
62
+ adjust_regex,
63
+ ensure_mode_int,
64
+ get_timestamp,
65
+ sed_delete,
66
+ sed_replace,
67
+ unix_path_join,
68
+ )
60
69
 
61
70
 
62
71
  @operation()
@@ -75,6 +84,9 @@ def download(
75
84
  headers: dict[str, str] | None = None,
76
85
  insecure=False,
77
86
  proxy: str | None = None,
87
+ temp_dir: str | Path | None = None,
88
+ extra_curl_args: dict[str, str] | None = None,
89
+ extra_wget_args: dict[str, str] | None = None,
78
90
  ):
79
91
  """
80
92
  Download files from remote locations using ``curl`` or ``wget``.
@@ -93,6 +105,9 @@ def download(
93
105
  + headers: optional dictionary of headers to set for the HTTP request
94
106
  + insecure: disable SSL verification for the HTTP request
95
107
  + proxy: simple HTTP proxy through which we can download files, form `http://<yourproxy>:<port>`
108
+ + temp_dir: use this custom temporary directory during the download
109
+ + extra_curl_args: optional dictionary with custom arguments for curl
110
+ + extra_wget_args: optional dictionary with custom arguments for wget
96
111
 
97
112
  **Example:**
98
113
 
@@ -148,11 +163,21 @@ def download(
148
163
 
149
164
  # If we download, always do user/group/mode as SSH user may be different
150
165
  if download:
151
- temp_file = host.get_temp_filename(dest)
166
+ temp_file = host.get_temp_filename(
167
+ dest, temp_directory=str(temp_dir) if temp_dir is not None else None
168
+ )
152
169
 
153
170
  curl_args: list[Union[str, StringCommand]] = ["-sSLf"]
154
171
  wget_args: list[Union[str, StringCommand]] = ["-q"]
155
172
 
173
+ if extra_curl_args:
174
+ for key, value in extra_curl_args.items():
175
+ curl_args.append(StringCommand(key, QuoteString(value)))
176
+
177
+ if extra_wget_args:
178
+ for key, value in extra_wget_args.items():
179
+ wget_args.append(StringCommand(key, QuoteString(value)))
180
+
156
181
  if proxy:
157
182
  curl_args.append(f"--proxy {proxy}")
158
183
  wget_args.append("-e use_proxy=yes")
@@ -423,9 +448,9 @@ def line(
423
448
  else:
424
449
  host.noop('line "{0}" exists in {1}'.format(replace or line, path))
425
450
 
426
- # Line(s) exists and we want to remove them, replace with nothing
451
+ # Line(s) exists and we want to remove them
427
452
  elif present_lines and not present:
428
- yield sed_replace(
453
+ yield sed_delete(
429
454
  path,
430
455
  match_line,
431
456
  "",
@@ -710,6 +735,21 @@ def _create_remote_dir(remote_filename, user, group):
710
735
  )
711
736
 
712
737
 
738
+ def _file_equal(local_path: str | IO[Any] | None, remote_path: str) -> bool:
739
+ if local_path is None:
740
+ return False
741
+ for fact, get_sum in [
742
+ (Sha1File, get_file_sha1),
743
+ (Md5File, get_file_md5),
744
+ (Sha256File, get_file_sha256),
745
+ ]:
746
+ remote_sum = host.get_fact(fact, path=remote_path)
747
+ if remote_sum:
748
+ local_sum = get_sum(local_path)
749
+ return local_sum == remote_sum
750
+ return False
751
+
752
+
713
753
  @operation(
714
754
  # We don't (currently) cache the local state, so there's nothing we can
715
755
  # update to flag the local file as present.
@@ -766,12 +806,11 @@ def get(
766
806
 
767
807
  # Remote file exists - check if it matches our local
768
808
  else:
769
- local_sum = get_file_sha1(dest)
770
- remote_sum = host.get_fact(Sha1File, path=src)
771
-
772
- # Check sha1sum, upload if needed
773
- if local_sum != remote_sum:
809
+ # Check hash sum, download if needed
810
+ if not _file_equal(dest, src):
774
811
  yield FileDownloadCommand(src, dest, remote_temp_filename=host.get_temp_filename(dest))
812
+ else:
813
+ host.noop("file {0} has already been downloaded".format(dest))
775
814
 
776
815
 
777
816
  @operation()
@@ -805,7 +844,9 @@ def put(
805
844
 
806
845
  ``mode``:
807
846
  When set to ``True`` the permissions of the local file are applied to the
808
- remote file after the upload is complete.
847
+ remote file after the upload is complete. If set to an octal value with
848
+ digits for at least user, group, and other, either as an ``int`` or
849
+ ``str``, those permissions will be used.
809
850
 
810
851
  ``create_remote_dir``:
811
852
  If the remote directory does not exist it will be created using the same
@@ -816,6 +857,11 @@ def put(
816
857
  This operation is not suitable for large files as it may involve copying
817
858
  the file before uploading it.
818
859
 
860
+ Currently, if the mode argument is anything other than a ``bool`` or a full
861
+ octal permission set and the remote file exists, the operation will always
862
+ behave as if the remote file does not match the specified permissions and
863
+ requires a change.
864
+
819
865
  **Examples:**
820
866
 
821
867
  .. code:: python
@@ -837,7 +883,7 @@ def put(
837
883
  # Upload IO objects as-is
838
884
  if hasattr(src, "read"):
839
885
  local_file = src
840
- local_sum = get_file_sha1(src)
886
+ local_sum_path = src
841
887
 
842
888
  # Assume string filename
843
889
  else:
@@ -849,9 +895,9 @@ def put(
849
895
  local_file = src
850
896
 
851
897
  if os.path.isfile(local_file):
852
- local_sum = get_file_sha1(local_file)
898
+ local_sum_path = local_file
853
899
  elif assume_exists:
854
- local_sum = None
900
+ local_sum_path = None
855
901
  else:
856
902
  raise IOError("No such file: {0}".format(local_file))
857
903
 
@@ -893,10 +939,7 @@ def put(
893
939
 
894
940
  # File exists, check sum and check user/group/mode if supplied
895
941
  else:
896
- remote_sum = host.get_fact(Sha1File, path=dest)
897
-
898
- # Check sha1sum, upload if needed
899
- if local_sum != remote_sum:
942
+ if not _file_equal(local_sum_path, dest):
900
943
  yield FileUploadCommand(
901
944
  local_file,
902
945
  dest,
@@ -1612,12 +1655,12 @@ def block(
1612
1655
  "2>/dev/null || stat -f %Lp",
1613
1656
  q_path,
1614
1657
  ") $OUT && ",
1615
- '(chown $(stat -c "%U:%G"',
1658
+ '(chown $(stat -c "%u:%g"',
1616
1659
  q_path,
1617
- "2>/dev/null) $OUT || ",
1618
- 'chown -n $(stat -f "%u:%g"',
1660
+ "2>/dev/null || ",
1661
+ 'stat -f "%u:%g"',
1619
1662
  q_path,
1620
- ') $OUT) && mv "$OUT"',
1663
+ '2>/dev/null ) $OUT) && mv "$OUT"',
1621
1664
  q_path,
1622
1665
  )
1623
1666
 
@@ -12,12 +12,14 @@ from pyinfra.facts.flatpak import FlatpakPackages
12
12
  @operation()
13
13
  def packages(
14
14
  packages: str | list[str] | None = None,
15
+ remote: str | None = None,
15
16
  present=True,
16
17
  ):
17
18
  """
18
19
  Install/remove a flatpak package
19
20
 
20
21
  + packages: List of packages
22
+ + remote: Source to install the application or runtime from
21
23
  + present: whether the package should be installed
22
24
 
23
25
  **Examples:**
@@ -30,6 +32,13 @@ def packages(
30
32
  packages="org.videolan.VLC",
31
33
  )
32
34
 
35
+ # Install vlc flatpak from flathub
36
+ flatpak.package(
37
+ name="Install vlc",
38
+ packages="org.videolan.VLC",
39
+ remote="flathub",
40
+ )
41
+
33
42
  # Install multiple flatpaks
34
43
  flatpak.package(
35
44
  name="Install vlc and kodi",
@@ -55,6 +64,12 @@ def packages(
55
64
  install_packages = []
56
65
  remove_packages = []
57
66
 
67
+ if remote is None:
68
+ remote = ""
69
+ else:
70
+ # ensure we have a space between the remote and packages
71
+ remote = remote.strip() + " "
72
+
58
73
  for package in packages:
59
74
  # it's installed
60
75
  if package in flatpak_packages:
@@ -73,7 +88,7 @@ def packages(
73
88
  host.noop(f"flatpak package {package} is not installed")
74
89
 
75
90
  if install_packages:
76
- yield " ".join(["flatpak", "install", "--noninteractive"] + install_packages)
91
+ yield f"flatpak install --noninteractive {remote}{' '.join(install_packages)}"
77
92
 
78
93
  if remove_packages:
79
- yield " ".join(["flatpak", "uninstall", "--noninteractive"] + remove_packages)
94
+ yield f"flatpak uninstall --noninteractive {' '.join(remove_packages)}"
pyinfra/operations/git.py CHANGED
@@ -9,7 +9,7 @@ import re
9
9
  from pyinfra import host
10
10
  from pyinfra.api import OperationError, operation
11
11
  from pyinfra.facts.files import Directory, File
12
- from pyinfra.facts.git import GitBranch, GitConfig, GitTrackingBranch
12
+ from pyinfra.facts.git import GitBranch, GitConfig, GitTag, GitTrackingBranch
13
13
 
14
14
  from . import files, ssh
15
15
  from .util.files import chown, unix_path_join
@@ -144,10 +144,14 @@ def repo(
144
144
  git_commands.append("clone {0} .".format(src))
145
145
  # Ensuring existing repo
146
146
  else:
147
+ is_tag = False
147
148
  if branch and host.get_fact(GitBranch, repo=dest) != branch:
148
149
  git_commands.append("fetch") # fetch to ensure we have the branch locally
149
150
  git_commands.append("checkout {0}".format(branch))
150
- if pull:
151
+ if branch and branch in host.get_fact(GitTag, repo=dest):
152
+ git_commands.append("checkout {0}".format(branch))
153
+ is_tag = True
154
+ if pull and not is_tag:
151
155
  if rebase:
152
156
  git_commands.append("pull --rebase")
153
157
  else:
@@ -20,6 +20,7 @@ from pyinfra.facts.server import (
20
20
  Groups,
21
21
  Home,
22
22
  Hostname,
23
+ Kernel,
23
24
  KernelModules,
24
25
  Locales,
25
26
  Mounts,
@@ -335,7 +336,15 @@ def mount(
335
336
  mounted_options = mounts[path]["options"]
336
337
  needed_options = set(options) - set(mounted_options)
337
338
  if needed_options:
338
- yield "mount -o remount,{0} {1}".format(options_string, path)
339
+ if host.get_fact(Kernel).strip() == "FreeBSD":
340
+ fs_type = mounts[path]["type"]
341
+ device = mounts[path]["device"]
342
+
343
+ yield "mount -o update,{options} -t {fs_type} {device} {path}".format(
344
+ options=options_string, fs_type=fs_type, device=device, path=path
345
+ )
346
+ else:
347
+ yield "mount -o remount,{0} {1}".format(options_string, path)
339
348
 
340
349
  else:
341
350
  host.noop(
@@ -890,22 +899,29 @@ def user(
890
899
 
891
900
  if create_home:
892
901
  args.append("-m")
893
- else:
902
+ elif os_type != "FreeBSD":
894
903
  args.append("-M")
895
904
 
896
- if password:
905
+ if password and os_type != "FreeBSD":
897
906
  args.append("-p '{0}'".format(password))
898
907
 
899
908
  # Users are often added by other operations (package installs), so check
900
909
  # for the user at runtime before adding.
901
910
  add_user_command = "useradd"
911
+
902
912
  if os_type == "FreeBSD":
903
913
  add_user_command = "pw useradd"
904
- yield "{0} -n {2} {1}".format(
905
- add_user_command,
906
- " ".join(args),
907
- user,
908
- )
914
+
915
+ if password:
916
+ yield "echo '{3}' | {0} -n {2} -H 0 {1}".format(
917
+ add_user_command, " ".join(args), user, password
918
+ )
919
+ else:
920
+ yield "{0} -n {2} {1}".format(
921
+ add_user_command,
922
+ " ".join(args),
923
+ user,
924
+ )
909
925
  else:
910
926
  yield "{0} {1} {2}".format(
911
927
  add_user_command,
@@ -930,16 +946,22 @@ def user(
930
946
  args.append("-g {0}".format(group))
931
947
 
932
948
  # Check secondary groups, if defined
933
- if groups and set(existing_user["groups"]) != set(groups):
949
+ if groups:
934
950
  if append:
935
- args.append("-a")
936
- args.append("-G {0}".format(",".join(groups)))
951
+ if not set(groups).issubset(existing_user["groups"]):
952
+ args.append("-a")
953
+ args.append("-G {0}".format(",".join(groups)))
954
+ elif set(existing_user["groups"]) != set(groups):
955
+ args.append("-G {0}".format(",".join(groups)))
937
956
 
938
957
  if comment and existing_user["comment"] != comment:
939
958
  args.append("-c '{0}'".format(comment))
940
959
 
941
960
  if password and existing_user["password"] != password:
942
- args.append("-p '{0}'".format(password))
961
+ if os_type == "FreeBSD":
962
+ yield "echo '{0}' | pw usermod -n {1} -H 0".format(password, user)
963
+ else:
964
+ args.append("-p '{0}'".format(password))
943
965
 
944
966
  # Need to mod the user?
945
967
  if args:
@@ -947,18 +969,6 @@ def user(
947
969
  yield "pw usermod -n {1} {0}".format(" ".join(args), user)
948
970
  else:
949
971
  yield "usermod {0} {1}".format(" ".join(args), user)
950
- if comment:
951
- existing_user["comment"] = comment
952
- if home:
953
- existing_user["home"] = home
954
- if shell:
955
- existing_user["shell"] = shell
956
- if group:
957
- existing_user["group"] = group
958
- if groups:
959
- existing_user["groups"] = groups
960
- if password:
961
- existing_user["password"] = password
962
972
 
963
973
  # Ensure home directory ownership
964
974
  if ensure_home and home:
@@ -117,6 +117,7 @@ def _remove_volume(**kwargs):
117
117
 
118
118
  def _create_network(**kwargs):
119
119
  command = []
120
+ aux_addresses = kwargs["aux_addresses"] if kwargs["aux_addresses"] else {}
120
121
  opts = kwargs["opts"] if kwargs["opts"] else []
121
122
  ipam_opts = kwargs["ipam_opts"] if kwargs["ipam_opts"] else []
122
123
  labels = kwargs["labels"] if kwargs["labels"] else []
@@ -146,6 +147,9 @@ def _create_network(**kwargs):
146
147
  if kwargs["attachable"]:
147
148
  command.append("--attachable")
148
149
 
150
+ for host, address in aux_addresses.items():
151
+ command.append("--aux-address '{0}={1}'".format(host, address))
152
+
149
153
  for opt in opts:
150
154
  command.append("--opt {0}".format(opt))
151
155