diff -Nru bundlewrap-4.17.2/.github/workflows/tests.yml bundlewrap-4.18.0/.github/workflows/tests.yml --- bundlewrap-4.17.2/.github/workflows/tests.yml 2023-05-05 16:24:17.000000000 +0000 +++ bundlewrap-4.18.0/.github/workflows/tests.yml 2024-04-02 00:31:45.000000000 +0000 @@ -13,11 +13,11 @@ strategy: matrix: python-version: - - "3.7" - "3.8" - "3.9" - "3.10" - "3.11" + - "3.12" steps: - uses: actions/checkout@v3 diff -Nru bundlewrap-4.17.2/CHANGELOG.md bundlewrap-4.18.0/CHANGELOG.md --- bundlewrap-4.17.2/CHANGELOG.md 2023-05-05 16:24:17.000000000 +0000 +++ bundlewrap-4.18.0/CHANGELOG.md 2024-04-02 00:31:45.000000000 +0000 @@ -1,3 +1,21 @@ +# 4.18.0 + +2024-04-01 + +* added support for Python 3.12 +* removed support for Python 3.7 +* added `download_timeout` for file items +* performance improvements +* improved display of long-running jobs +* improved handling of connection errors +* improved handling of services on OpenBSD +* fixed fixing of symlink ownership +* fixed detection of installed packages with `pacman` +* fixed handling of binary files with `bw pw -f` +* fixed missing setuptools dependency +* fixed `block_concurrent()` not working if item provides canned actions + + # 4.17.2 2023-05-05 diff -Nru bundlewrap-4.17.2/README.md bundlewrap-4.18.0/README.md --- bundlewrap-4.17.2/README.md 2023-05-05 16:24:17.000000000 +0000 +++ bundlewrap-4.18.0/README.md 2024-04-02 00:31:45.000000000 +0000 @@ -22,4 +22,4 @@ ------------------------------------------------------------------------ -BundleWrap is © 2013 - 2023 [Torsten Rehn](mailto:torsten@rehn.email) +BundleWrap is © 2013 - 2024 [Torsten Rehn](mailto:torsten@rehn.email) diff -Nru bundlewrap-4.17.2/bundlewrap/__init__.py bundlewrap-4.18.0/bundlewrap/__init__.py --- bundlewrap-4.17.2/bundlewrap/__init__.py 2023-05-05 16:24:17.000000000 +0000 +++ bundlewrap-4.18.0/bundlewrap/__init__.py 2024-04-02 00:31:45.000000000 +0000 @@ -1,2 +1,2 @@ -VERSION = (4, 17, 2) +VERSION = (4, 18, 0) VERSION_STRING = ".".join([str(v) for v in VERSION]) diff -Nru bundlewrap-4.17.2/bundlewrap/cmdline/metadata.py bundlewrap-4.18.0/bundlewrap/cmdline/metadata.py --- bundlewrap-4.17.2/bundlewrap/cmdline/metadata.py 2023-05-05 16:24:17.000000000 +0000 +++ bundlewrap-4.18.0/bundlewrap/cmdline/metadata.py 2024-04-02 00:31:45.000000000 +0000 @@ -1,7 +1,6 @@ -from collections import OrderedDict from contextlib import suppress from decimal import Decimal -from sys import exit, version_info +from sys import exit from ..metadata import metadata_to_json from ..utils import Fault, list_starts_with @@ -79,10 +78,7 @@ def _sort_dict_colorblind(old_dict): - if version_info < (3, 7): - new_dict = OrderedDict() - else: - new_dict = {} + new_dict = {} for key in sorted(old_dict.keys(), key=ansi_clean): if isinstance(old_dict[key], dict): diff -Nru bundlewrap-4.17.2/bundlewrap/cmdline/pw.py bundlewrap-4.18.0/bundlewrap/cmdline/pw.py --- bundlewrap-4.17.2/bundlewrap/cmdline/pw.py 2023-05-05 16:24:17.000000000 +0000 +++ bundlewrap-4.18.0/bundlewrap/cmdline/pw.py 2024-04-02 00:31:45.000000000 +0000 @@ -48,9 +48,10 @@ content = repo.vault.decrypt_file( args['string'], key=args['key'], + binary=True, ).value with open(join(repo.data_dir, args['file']), 'wb') as f: - f.write(content.encode('utf-8')) + f.write(content) else: try: key, cryptotext = args['string'].split("$", 1) diff -Nru bundlewrap-4.17.2/bundlewrap/deps.py bundlewrap-4.18.0/bundlewrap/deps.py --- bundlewrap-4.17.2/bundlewrap/deps.py 2023-05-05 16:24:17.000000000 +0000 +++ bundlewrap-4.18.0/bundlewrap/deps.py 2024-04-02 00:31:45.000000000 +0000 @@ -125,13 +125,14 @@ For each item, records all items that need that item in ._incoming_needs. """ + mapping = {} for item in items: - item._incoming_needs = set() - for depending_item in items: - if item == depending_item: - continue - if item.id in depending_item._flattened_deps_needs: - item._incoming_needs.add(depending_item) + for other_item_id in item._flattened_deps_needs: + mapping.setdefault(other_item_id, set()) + mapping[other_item_id].add(item) + + for item in items: + item._incoming_needs = mapping.get(item.id, set()) def _prepare_auto_attrs(items): @@ -149,6 +150,8 @@ def _prepare_deps(items): + selector_cache = {} + for item in items: item._deps = set() # holds all item ids blocking execution of that item for dep_type, deps in ( @@ -158,20 +161,28 @@ setattr(item, '_deps_' + dep_type, set()) for dep in deps: try: - resolved_deps = tuple(resolve_selector(dep, items, originating_item_id=item.id)) - except NoSuchItem: - raise ItemDependencyError(_( - "'{item}' in bundle '{bundle}' has a dependency ({dep_type}) " - "on '{dep}', which doesn't exist" - ).format( - item=item.id, - bundle=item.bundle.name, - dep=dep, - dep_type=dep_type, - )) - else: - item._deps.update(resolved_deps) - getattr(item, '_deps_' + dep_type).update(resolved_deps) + resolved_deps = selector_cache[dep] + except KeyError: + try: + resolved_deps = tuple(resolve_selector(dep, items)) + except NoSuchItem: + raise ItemDependencyError(_( + "'{item}' in bundle '{bundle}' has a dependency ({dep_type}) " + "on '{dep}', which doesn't exist" + ).format( + item=item.id, + bundle=item.bundle.name, + dep=dep, + dep_type=dep_type, + )) + + selector_cache[dep] = resolved_deps + + # Don't put the item itself into its own deps. + resolved_deps = tuple(filter(lambda i: i.id != item.id, resolved_deps)) + + item._deps.update(resolved_deps) + getattr(item, '_deps_' + dep_type).update(resolved_deps) def _inject_canned_actions(items): @@ -269,9 +280,13 @@ )) processed_items = set() for item in type_items: - # disregard deps to items of other types + # disregard deps to items of other types and to canned + # actions item.__deps = set(filter( - lambda dep: dep.split(":", 1)[0] in blocked_types, + lambda dep: ( + dep.split(":", 1)[0] in blocked_types and + dep.count(":") == 1 + ), item._flattened_deps, )) previous_item = None diff -Nru bundlewrap-4.17.2/bundlewrap/exceptions.py bundlewrap-4.18.0/bundlewrap/exceptions.py --- bundlewrap-4.17.2/bundlewrap/exceptions.py 2023-05-05 16:24:17.000000000 +0000 +++ bundlewrap-4.18.0/bundlewrap/exceptions.py 2024-04-02 00:31:45.000000000 +0000 @@ -63,6 +63,13 @@ pass +class TransportException(Exception): + """ + Raised when there is an error on the transport layer, e.g. SSH failures. + """ + pass + + class RepositoryError(Exception): """ Indicates that somethings is wrong with the current repository. diff -Nru bundlewrap-4.17.2/bundlewrap/items/files.py bundlewrap-4.18.0/bundlewrap/items/files.py --- bundlewrap-4.17.2/bundlewrap/items/files.py 2023-05-05 16:24:17.000000000 +0000 +++ bundlewrap-4.18.0/bundlewrap/items/files.py 2024-04-02 00:31:45.000000000 +0000 @@ -3,6 +3,10 @@ from collections import defaultdict from contextlib import contextmanager, suppress from datetime import datetime +try: + from functools import cache +except ImportError: # Python 3.8 + cache = lambda f: f from hashlib import md5 from os import getenv, getpid, makedirs, mkdir, rmdir from os.path import basename, dirname, exists, isfile, join, normpath @@ -17,6 +21,7 @@ from jinja2 import Environment, FileSystemLoader from mako.lookup import TemplateLookup from mako.template import Template +from requests import head from bundlewrap.exceptions import BundleError, FaultUnavailable, TemplateError from bundlewrap.items import BUILTIN_ITEM_ATTRIBUTES, Item @@ -31,6 +36,16 @@ DIFF_MAX_FILE_SIZE = 1024 * 1024 * 5 # bytes +@cache +def check_download(url, timeout): + try: + head(url, timeout=timeout).raise_for_status() + except Exception as exc: + return exc + else: + return None + + def content_processor_base64(item): # .encode() is required for pypy3 only return b64decode(item._template_content.encode()) @@ -152,7 +167,7 @@ # Since we only download the file once per process, there's no point # in displaying the node name here. The file may be used on multiple # nodes. - with io.job(_("{} {} waiting for download".format(bold(item.node.name), bold(item.id)))): + with io.job(_("{} {} waiting for download").format(bold(item.node.name), bold(item.id))): while True: try: mkdir(lock_dir) @@ -168,8 +183,16 @@ f"{item.node.name}:{item.id}: " f"starting download from {item.attributes['source']}" ) - with io.job(_("{} {} downloading file".format(bold(item.node.name), bold(item.id)))): - download(item.attributes['source'], file_path) + with io.job(_("{node} {item} downloading from {url}").format( + node=bold(item.node.name), + item=bold(item.id), + url=item.attributes['source'], + )): + download( + item.attributes['source'], + file_path, + timeout=item.attributes['download_timeout'], + ) io.debug( f"{item.node.name}:{item.id}: " f"finished download from {item.attributes['source']}" @@ -236,6 +259,7 @@ 'content_hash': None, 'context': None, 'delete': False, + 'download_timeout': 60.0, 'encoding': "utf-8", 'group': "root", 'mode': "0644", @@ -316,14 +340,18 @@ def _fix_content_hash(self, status): with self._write_local_file() as local_path: - self.node.upload( - local_path, - self.name, - mode=self.attributes['mode'], - owner=self.attributes['owner'] or "", - group=self.attributes['group'] or "", - may_fail=True, - ) + with io.job(_("{} {} uploading to node").format( + bold(self.node.name), + bold(self.id), + )): + self.node.upload( + local_path, + self.name, + mode=self.attributes['mode'], + owner=self.attributes['owner'] or "", + group=self.attributes['group'] or "", + may_fail=True, + ) def _fix_mode(self, status): if self.node.os in self.node.OS_FAMILY_BSD: @@ -498,7 +526,11 @@ return self.content.decode(self.attributes['encoding']) def test(self): - if self.attributes['source'] and not exists(self.template): + if ( + self.attributes['source'] + and self.attributes['content_type'] != 'download' + and not exists(self.template) + ): raise BundleError(_( "{item} from bundle '{bundle}' refers to missing " "file '{path}' in its 'source' attribute" @@ -508,7 +540,22 @@ path=self.template, )) - if not self.attributes['delete'] and not self.attributes['content_type'] == 'any': + if ( + self.attributes['delete'] + or self.attributes['content_type'] == 'any' + ): + pass + elif ( + self.attributes['content_type'] == 'download' + and not self.attributes['content_hash'] + ): + download_exc = check_download( + self.attributes['source'], + self.attributes['download_timeout'], + ) + if download_exc is not None: + raise download_exc + else: with self._write_local_file() as local_path: if self.attributes['test_with']: cmd = self.attributes['test_with'].format(quote(local_path)) @@ -562,6 +609,21 @@ "not of type 'download'" ).format(item=item_id, bundle=bundle.name)) + if 'download_timeout' in attributes and attributes.get('content_type') != 'download': + raise BundleError(_( + "{item} from bundle '{bundle}' specified 'download_timeout', but is " + "not of type 'download'" + ).format(item=item_id, bundle=bundle.name)) + + if 'download_timeout' in attributes: + if ( + not isinstance(attributes['download_timeout'], float) + or attributes['download_timeout'] <= 0.0 + ): + raise BundleError(_( + "download_timeout for {item} from bundle '{bundle}' must be a float > 0.0" + ).format(item=item_id, bundle=bundle.name)) + if attributes.get('content_type') == 'download': if 'source' not in attributes: raise BundleError(_( diff -Nru bundlewrap-4.17.2/bundlewrap/items/git_deploy.py bundlewrap-4.18.0/bundlewrap/items/git_deploy.py --- bundlewrap-4.17.2/bundlewrap/items/git_deploy.py 2023-05-05 16:24:17.000000000 +0000 +++ bundlewrap-4.18.0/bundlewrap/items/git_deploy.py 2024-04-02 00:31:45.000000000 +0000 @@ -5,6 +5,7 @@ from shlex import quote from shutil import rmtree from subprocess import PIPE, Popen +from sys import version_info from tempfile import gettempdir, NamedTemporaryFile from time import sleep @@ -211,14 +212,26 @@ " ".join(cmdline), repo_dir, )) - git_process = Popen( - cmdline, - cwd=repo_dir, - env=git_env, - preexec_fn=setpgrp, - stderr=PIPE, - stdout=PIPE, - ) + + if version_info < (3, 11): + git_process = Popen( + cmdline, + cwd=repo_dir, + env=git_env, + preexec_fn=setpgrp, + stderr=PIPE, + stdout=PIPE, + ) + else: + git_process = Popen( + cmdline, + cwd=repo_dir, + env=git_env, + process_group=0, + stderr=PIPE, + stdout=PIPE, + ) + stdout, stderr = git_process.communicate() result = RunResult() result.stdout = stdout diff -Nru bundlewrap-4.17.2/bundlewrap/items/pkg_pacman.py bundlewrap-4.18.0/bundlewrap/items/pkg_pacman.py --- bundlewrap-4.17.2/bundlewrap/items/pkg_pacman.py 2023-05-05 16:24:17.000000000 +0000 +++ bundlewrap-4.18.0/bundlewrap/items/pkg_pacman.py 2024-04-02 00:31:45.000000000 +0000 @@ -24,7 +24,7 @@ def pkg_all_installed(self): pkgs = self.run("pacman -Qq").stdout.decode('utf-8') for line in pkgs.splitlines(): - yield "{}:{}".format(self.ITEM_TYPE_NAME, line.split()) + yield "{}:{}".format(self.ITEM_TYPE_NAME, line.split()[0]) def pkg_install(self): if self.attributes['tarball']: @@ -37,11 +37,14 @@ self.run("pacman --noconfirm -S {}".format(quote(self.name)), may_fail=True) def pkg_installed(self): - result = self.run( - "pacman -Q {}".format(quote(self.name)), - may_fail=True, - ) - return result.return_code == 0 + # Don't use "pacman -Q $name" here because that doesn't work as + # expected with "provides". When package A has "provides: B", + # then "pacman -Q B" shows info for package A. This is not what + # we want, we really want to know if package B (exactly that) is + # installed. + # + # This could lead to issues like #688. + return "{}:{}".format(self.ITEM_TYPE_NAME, self.name) in self.pkg_all_installed() def pkg_remove(self): self.run("pacman --noconfirm -Rs {}".format(quote(self.name)), may_fail=True) diff -Nru bundlewrap-4.17.2/bundlewrap/items/svc_openbsd.py bundlewrap-4.18.0/bundlewrap/items/svc_openbsd.py --- bundlewrap-4.17.2/bundlewrap/items/svc_openbsd.py 2023-05-05 16:24:17.000000000 +0000 +++ bundlewrap-4.18.0/bundlewrap/items/svc_openbsd.py 2024-04-02 00:31:45.000000000 +0000 @@ -6,16 +6,16 @@ def svc_start(node, svcname): - return node.run("/etc/rc.d/{} start".format(quote(svcname)), may_fail=True) + return node.run("rcctl start {}".format(quote(svcname)), may_fail=True) def svc_running(node, svcname): - result = node.run("/etc/rc.d/{} check".format(quote(svcname)), may_fail=True) + result = node.run("rcctl check {}".format(quote(svcname)), may_fail=True) return "ok" in result.stdout_text def svc_stop(node, svcname): - return node.run("/etc/rc.d/{} stop".format(quote(svcname)), may_fail=True) + return node.run("rcctl stop {}".format(quote(svcname)), may_fail=True) def svc_enable(node, svcname): @@ -23,10 +23,7 @@ def svc_enabled(node, svcname): - result = node.run( - "rcctl ls on | grep '^{}$'".format(svcname), - may_fail=True, - ) + result = node.run("rcctl get {} status".format(quote(svcname)), may_fail=True) return result.return_code == 0 @@ -72,15 +69,15 @@ def get_canned_actions(self): return { 'stop': { - 'command': "/etc/rc.d/{0} stop".format(self.name), + 'command': "rcctl stop {0}".format(self.name), 'needed_by': {self.id}, }, 'stopstart': { - 'command': "/etc/rc.d/{0} stop && /etc/rc.d/{0} start".format(self.name), + 'command': "rcctl stop {0} && rcctl start {0}".format(self.name), 'needs': {self.id}, }, 'restart': { - 'command': "/etc/rc.d/{} restart".format(self.name), + 'command': "rcctl restart {}".format(self.name), 'needs': { # make sure we don't restart and stopstart simultaneously f"{self.id}:stopstart", diff -Nru bundlewrap-4.17.2/bundlewrap/items/symlinks.py bundlewrap-4.18.0/bundlewrap/items/symlinks.py --- bundlewrap-4.17.2/bundlewrap/items/symlinks.py 2023-05-05 16:24:17.000000000 +0000 +++ bundlewrap-4.18.0/bundlewrap/items/symlinks.py 2024-04-02 00:31:45.000000000 +0000 @@ -49,16 +49,13 @@ if status.must_be_created or 'type' in status.keys_to_fix: # fixing the type fixes everything self._fix_type(status) - return + elif 'target' in status.keys_to_fix: + # Same, also fixes ownership. + self._fix_target(status) + elif 'owner' in status.keys_to_fix or 'group' in status.keys_to_fix: + self._fix_ownership(status) - for fix_type in ('target', 'owner', 'group'): - if fix_type in status.keys_to_fix: - if fix_type == 'group' and 'owner' in status.keys_to_fix: - # owner and group are fixed with a single chown - continue - getattr(self, "_fix_" + fix_type)(status) - - def _fix_owner(self, status): + def _fix_ownership(self, status): group = self.attributes['group'] or "" if group: group = ":" + quote(group) @@ -71,7 +68,6 @@ group, quote(self.name), )) - _fix_group = _fix_owner def _fix_target(self, status): if self.node.os in self.node.OS_FAMILY_BSD: @@ -85,6 +81,11 @@ quote(self.name), )) + # Fixing the target essentially creates a new symlink, so we + # must also fix ownership afterwards. + if self.attributes['owner'] or self.attributes['group']: + self._fix_ownership(status) + def _fix_type(self, status): self.run("rm -rf -- {}".format(quote(self.name))) self.run("mkdir -p -- {}".format(quote(dirname(self.name)))) @@ -93,7 +94,7 @@ quote(self.name), )) if self.attributes['owner'] or self.attributes['group']: - self._fix_owner(status) + self._fix_ownership(status) def get_auto_deps(self, items): deps = [] diff -Nru bundlewrap-4.17.2/bundlewrap/items/zfs_dataset.py bundlewrap-4.18.0/bundlewrap/items/zfs_dataset.py --- bundlewrap-4.17.2/bundlewrap/items/zfs_dataset.py 2023-05-05 16:24:17.000000000 +0000 +++ bundlewrap-4.18.0/bundlewrap/items/zfs_dataset.py 2024-04-02 00:31:45.000000000 +0000 @@ -1,4 +1,4 @@ -from pipes import quote +from shlex import quote from bundlewrap.items import Item diff -Nru bundlewrap-4.17.2/bundlewrap/items/zfs_pool.py bundlewrap-4.18.0/bundlewrap/items/zfs_pool.py --- bundlewrap-4.17.2/bundlewrap/items/zfs_pool.py 2023-05-05 16:24:17.000000000 +0000 +++ bundlewrap-4.18.0/bundlewrap/items/zfs_pool.py 2024-04-02 00:31:45.000000000 +0000 @@ -1,5 +1,5 @@ from collections import Counter -from pipes import quote +from shlex import quote from bundlewrap.exceptions import BundleError from bundlewrap.items import Item diff -Nru bundlewrap-4.17.2/bundlewrap/lock.py bundlewrap-4.18.0/bundlewrap/lock.py --- bundlewrap-4.17.2/bundlewrap/lock.py 2023-05-05 16:24:17.000000000 +0000 +++ bundlewrap-4.18.0/bundlewrap/lock.py 2024-04-02 00:31:45.000000000 +0000 @@ -6,7 +6,12 @@ from socket import gethostname from time import time -from .exceptions import NodeLockedException, NoSuchNode, RemoteException +from .exceptions import ( + NodeLockedException, + NoSuchNode, + RemoteException, + TransportException, +) from .utils import cached_property, tempfile from .utils.text import ( blue, @@ -100,7 +105,7 @@ self.locking_node.download(self._hard_lock_file(), local_path) with open(local_path, 'r') as fp: return json.load(fp) - except (RemoteException, ValueError): + except (RemoteException, TransportException, ValueError): io.stderr(_( "{x} {node_bold} corrupted hard lock: " "unable to read or parse lock file contents " diff -Nru bundlewrap-4.17.2/bundlewrap/metagen.py bundlewrap-4.18.0/bundlewrap/metagen.py --- bundlewrap-4.17.2/bundlewrap/metagen.py 2023-05-05 16:24:17.000000000 +0000 +++ bundlewrap-4.18.0/bundlewrap/metagen.py 2024-04-02 00:31:45.000000000 +0000 @@ -466,6 +466,15 @@ with suppress(KeyError): del self._reactors_with_keyerrors[self._current_reactor] + if new_metadata is None: + raise ValueError(_( + "{reactor_name} on {node_name} returned None instead of a dict " + "(missing return?)" + ).format( + node_name=node.name, + reactor_name=reactor_name, + )) + if self._verify_reactor_provides and getattr(reactor, '_provides', None): extra_paths = extra_paths_in_dict(new_metadata, reactor._provides) if extra_paths: diff -Nru bundlewrap-4.17.2/bundlewrap/node.py bundlewrap-4.18.0/bundlewrap/node.py --- bundlewrap-4.17.2/bundlewrap/node.py 2023-05-05 16:24:17.000000000 +0000 +++ bundlewrap-4.18.0/bundlewrap/node.py 2024-04-02 00:31:45.000000000 +0000 @@ -19,6 +19,7 @@ NoSuchBundle, NoSuchGroup, RemoteException, + TransportException, RepositoryError, SkipNode, ) @@ -694,7 +695,7 @@ self.run("true") elif self.os == 'routeros': self.run_routeros("/nothing") - except RemoteException as exc: + except (RemoteException, TransportException) as exc: io.stdout(_("{x} {node} Connection error: {msg}").format( msg=exc, node=bold(self.name), @@ -1067,7 +1068,7 @@ # See comment in node.apply(). if node.os in node.OS_FAMILY_UNIX: node.run("true") - except RemoteException as exc: + except (RemoteException, TransportException) as exc: io.stdout(_("{x} {node} Connection error: {msg}").format( msg=exc, node=bold(node.name), diff -Nru bundlewrap-4.17.2/bundlewrap/operations.py bundlewrap-4.18.0/bundlewrap/operations.py --- bundlewrap-4.17.2/bundlewrap/operations.py 2023-05-05 16:24:17.000000000 +0000 +++ bundlewrap-4.18.0/bundlewrap/operations.py 2024-04-02 00:31:45.000000000 +0000 @@ -1,13 +1,15 @@ from contextlib import suppress from datetime import datetime +from fcntl import fcntl, F_GETFL, F_SETFL from shlex import quote -from select import select +from select import poll, POLLIN, POLLOUT, POLLERR, POLLHUP from shlex import split -from subprocess import Popen, PIPE -from threading import Event, Thread, Lock -from os import close, environ, pipe, read, setpgrp +from subprocess import Popen +from sys import version_info +from threading import Lock +from os import close, environ, pipe, read, setpgrp, write, O_NONBLOCK -from .exceptions import RemoteException +from .exceptions import RemoteException, TransportException from .utils import cached_property from .utils.text import force_text, LineBuffer, mark_for_translation as _, randstr from .utils.ui import io @@ -19,28 +21,6 @@ ROUTEROS_CONNECTIONS_LOCK = Lock() -def output_thread_body(line_buffer, read_fd, quit_event, read_until_eof): - # see run() for details - while True: - r, w, x = select([read_fd], [], [], 0.1) - if r: - chunk = read(read_fd, 1024) - if chunk: - line_buffer.write(chunk) - else: # EOF - return - elif quit_event.is_set() and not read_until_eof: - # one last chance to read output after the child process - # has died - while True: - r, w, x = select([read_fd], [], [], 0) - if r: - line_buffer.write(read(read_fd, 1024)) - else: - break - return - - def download( hostname, remote_path, @@ -94,6 +74,37 @@ return force_text(self.stdout) +class ManagedPoller: + def __init__(self): + self.poller = poll() + self._fds = set() + + def fd_is_open(self, fd): + return fd in self._fds + + def get_open_fds(self): + return self._fds + + def has_open_fds(self): + return len(self._fds) > 0 + + def poll(self, *args, **kwargs): + return self.poller.poll(*args, **kwargs) + + def register(self, fd, *args, **kwargs): + # Our register() and unregister() first call the actual poller's + # functions because they might throw exceptions. Only if they + # don't do we add the FD to our set. + ret = self.poller.register(fd, *args, **kwargs) + self._fds.add(fd) + return ret + + def unregister(self, fd, *args, **kwargs): + ret = self.poller.unregister(fd, *args, **kwargs) + self._fds.remove(fd) + return ret + + def run_local( command, data_stdin=None, @@ -113,77 +124,154 @@ # Create pipes which will be used by the SSH child process. We do # not use subprocess.PIPE because we need to be able to continuously # check those pipes for new output, so we can feed it to the - # LineBuffers during `bw run`. - stdout_fd_r, stdout_fd_w = pipe() + # LineBuffers during `bw run`. We can't use .communicate(). stderr_fd_r, stderr_fd_w = pipe() + stdout_fd_r, stdout_fd_w = pipe() + + close_after_fork = [stdout_fd_w, stderr_fd_w] + + lbs_for_fds = { + stderr_fd_r: stderr_lb, + stdout_fd_r: stdout_lb, + } + + # Python's own poll objects lack the ability to track which FDs are + # currently registered. We must do this ourselves. + poller = ManagedPoller() + poller.register(stderr_fd_r, POLLIN) + poller.register(stdout_fd_r, POLLIN) + + # It's important that SSH never gets connected to the terminal, even + # if we do not send data to the child. Otherwise, SSH can steal user + # input. + stdin_fd_r, stdin_fd_w = pipe() + if data_stdin is None: + data_stdin = b'' + close_after_fork += [stdin_fd_r, stdin_fd_w] + else: + poller.register(stdin_fd_w, POLLOUT) + close_after_fork += [stdin_fd_r] + + fcntl(stdin_fd_w, F_SETFL, fcntl(stdin_fd_w, F_GETFL) | O_NONBLOCK) cmd_id = randstr(length=4).upper() io.debug("running command with ID {}: {}".format(cmd_id, " ".join(command))) start = datetime.utcnow() - # Launch the child process. It's important that SSH gets a dummy - # stdin, i.e. it must *not* read from the terminal. Otherwise, it - # can steal user input. - child_process = Popen( - command, - preexec_fn=setpgrp, - shell=shell, - stdin=PIPE, - stderr=stderr_fd_w, - stdout=stdout_fd_w, - ) - io._child_pids.append(child_process.pid) + # A word on process groups: We create a new process group that all + # child processes live in. The point is to avoid SIGINT signals to + # reach our children: When a user presses ^C in their terminal, the + # signal will be sent to the foreground process group only. + # + # Our concept of "soft shutdown" hinges on this behavior. If we + # didn't create a new process group, child processes would die + # instantly. + # + # Older versions of Python only allow you to do this by setting + # preexec_fn=. Using this mechanism has a huge impact on performance + # (we did not investigate this further). As of Python 3.11, we can + # use process_group=, which has no such impact. + if version_info < (3, 11): + child_process = Popen( + command, + preexec_fn=setpgrp, + shell=shell, + stdin=stdin_fd_r, + stderr=stderr_fd_w, + stdout=stdout_fd_w, + ) + else: + child_process = Popen( + command, + process_group=0, + shell=shell, + stdin=stdin_fd_r, + stderr=stderr_fd_w, + stdout=stdout_fd_w, + ) - if data_stdin is not None: - child_process.stdin.write(data_stdin) + io._child_pids.append(child_process.pid) - quit_event = Event() - stdout_thread = Thread( - args=(stdout_lb, stdout_fd_r, quit_event, True), - target=output_thread_body, - ) - stderr_thread = Thread( - args=(stderr_lb, stderr_fd_r, quit_event, False), - target=output_thread_body, - ) - stdout_thread.start() - stderr_thread.start() + for fd in close_after_fork: + close(fd) try: - child_process.communicate() + while poller.has_open_fds(): + fdevents = poller.poll() + + fds_to_close = [] + + # POSIX says that POLLIN and POLLHUP are not mutually + # exclusive. We must be prepared to read from an fd "until + # EOF", which is the traditional return value of 0 (b'' in + # Python). When we see read() == 0, we must mark the fd for + # closing right away and must not wait for a subsequent + # POLLHUP. + # + # We must *also* be prepared to mark the fd for closing if + # POLLHUP is set, even if we never saw read() == 0. + # + # (If both POLLIN and POLLHUP are set, it means there is + # pending data and the fd has already been closed on the + # other end. We must read all that stuff and then we can + # close the fd on our end as well. But that "pending data" + # can also be "nothing", which signals EOF.) + # + # OSes behave slightly different here. We should stick to + # POSIX as best as we can. + + for fd, event in fdevents: + if event & POLLIN: + chunk = read(fd, 8192) + if chunk == b'': + fds_to_close.append(fd) + else: + lbs_for_fds[fd].write(chunk) + elif event & POLLOUT: + if len(data_stdin) > 0: + written = write(fd, data_stdin) + data_stdin = data_stdin[written:] + else: + fds_to_close.append(fd) + elif event & (POLLERR | POLLHUP): + fds_to_close.append(fd) + + for fd in fds_to_close: + if poller.fd_is_open(fd): + close(fd) + poller.unregister(fd) + + # We can't read on stderr until EOF. + # + # A user could use SSH multiplexing with + # auto-forking (e.g., "ControlPersist 10m"). In this + # case, OpenSSH forks another process which holds + # the "master" connection. This forked process + # *inherits* our pipes (at least stderr). Thus, only + # when that master process finally terminates + # (possibly after many minutes), we will be informed + # about EOF on our stderr pipe. That doesn't work, + # bw will hang. + # + # We interpret an EOF or an error on stdout as "the + # child has terminated". In that case, we give up on + # stderr as well. + if fd == stdout_fd_r and poller.fd_is_open(stderr_fd_r): + close(stderr_fd_r) + poller.unregister(stderr_fd_r) finally: - # Once we end up here, the child process has terminated. - # - # Now, the big question is: Why do we need an Event here? - # - # Problem is, a user could use SSH multiplexing with - # auto-forking (e.g., "ControlPersist 10m"). In this case, - # OpenSSH forks another process which holds the "master" - # connection. This forked process *inherits* our pipes (at least - # for stderr). Thus, only when that master process finally - # terminates (possibly after many minutes), we will be informed - # about EOF on our stderr pipe. That doesn't work. bw will hang. - # - # So, instead, we use a busy loop in output_thread_body() which - # checks for quit_event being set. Unfortunately there is no way - # to be absolutely sure that we received all output from stderr - # because we never get a proper EOF there. All we can do is hope - # that all output has arrived on the reading end of the pipe by - # the time the quit_event is checked in the thread. - # - # Luckily stdout is a somewhat simpler affair: we can just close - # the writing end of the pipe, causing the reader thread to - # shut down as it sees the EOF. io._child_pids.remove(child_process.pid) - quit_event.set() - close(stdout_fd_w) - stdout_thread.join() - stderr_thread.join() - stdout_lb.close() + stderr_lb.close() - for fd in (stdout_fd_r, stderr_fd_r, stderr_fd_w): + stdout_lb.close() + + # In case we get an exception, make sure to close all + # descriptors that are still open. + for fd in poller.get_open_fds(): close(fd) + child_process.wait() + io.debug("command with ID {} finished with return code {}".format( cmd_id, child_process.returncode, @@ -206,7 +294,6 @@ raise_for_return_codes=( 126, # command not executable 127, # command not found - 255, # SSH error ), log_function=None, username=None, # SSH auth @@ -240,7 +327,27 @@ log_function=log_function, ) - if result.return_code != 0: + if result.return_code < 0: + error_msg = _( + "SSH process running '{command}' on '{host}': Terminated by signal {rcode}" + ).format( + command=command, + host=hostname, + rcode=-result.return_code, + ) + io.debug(error_msg) + raise TransportException(error_msg) + elif result.return_code == 255: + error_msg = _( + "SSH transport error while running '{command}' on '{host}': {result}" + ).format( + command=command, + host=hostname, + result=force_text(result.stdout) + force_text(result.stderr), + ) + io.debug(error_msg) + raise TransportException(error_msg) + elif result.return_code != 0: error_msg = _( "Non-zero return code ({rcode}) running '{command}' " "on '{host}':\n\n{result}\n\n" @@ -251,6 +358,7 @@ result=force_text(result.stdout) + force_text(result.stderr), ) io.debug(error_msg) + if not ignore_failure or result.return_code in raise_for_return_codes: raise RemoteException(error_msg) return result diff -Nru bundlewrap-4.17.2/bundlewrap/repo.py bundlewrap-4.18.0/bundlewrap/repo.py --- bundlewrap-4.17.2/bundlewrap/repo.py 2023-05-05 16:24:17.000000000 +0000 +++ bundlewrap-4.18.0/bundlewrap/repo.py 2024-04-02 00:31:45.000000000 +0000 @@ -4,7 +4,7 @@ from os import listdir, mkdir, walk from os.path import abspath, dirname, isdir, isfile, join -from pkg_resources import DistributionNotFound, require, VersionConflict +from pkg_resources import DistributionNotFound, require, VersionConflict # needs setuptools try: from tomllib import loads as toml_load except ImportError: @@ -31,7 +31,7 @@ ) from .utils.scm import get_git_branch, get_git_clean, get_rev from .utils.dicts import hash_statedict -from .utils.text import mark_for_translation as _, red, validate_name +from .utils.text import bold, mark_for_translation as _, red, validate_name from .utils.ui import io DIRNAME_BUNDLES = "bundles" @@ -124,7 +124,11 @@ # define a function that calls all hook functions def hook(*args, **kwargs): for filename in files: - self.__module_cache[filename][event](*args, **kwargs) + with io.job(_("{event} Running hooks from {filename}").format( + event=bold(event), + filename=filename, + )): + self.__module_cache[filename][event](*args, **kwargs) self.__hook_cache[event] = hook return self.__hook_cache[event] @@ -199,10 +203,11 @@ self._get_all_attr_code_cache = {} self._get_all_attr_result_cache = {} - if repo_path is not None: - self.populate_from_path(self.path) - else: - self.item_classes = list(self.items_from_dir(items.__path__[0])) + with io.job("Loading repository"): + if repo_path is not None: + self.populate_from_path(self.path) + else: + self.item_classes = list(self.items_from_dir(items.__path__[0])) def __eq__(self, other): if self.path == "/dev/null": diff -Nru bundlewrap-4.17.2/bundlewrap/secrets.py bundlewrap-4.18.0/bundlewrap/secrets.py --- bundlewrap-4.17.2/bundlewrap/secrets.py 2023-05-05 16:24:17.000000000 +0000 +++ bundlewrap-4.18.0/bundlewrap/secrets.py 2024-04-02 00:31:45.000000000 +0000 @@ -81,7 +81,7 @@ key, cryptotext = self._determine_key_to_use(cryptotext.encode('utf-8'), key, cryptotext) return Fernet(key).decrypt(cryptotext).decode('utf-8') - def _decrypt_file(self, source_path=None, key=None): + def _decrypt_file(self, source_path=None, binary=False, key=None): """ Decrypts the file at source_path (relative to data/) and returns the plaintext as unicode. @@ -93,7 +93,10 @@ key, cryptotext = self._determine_key_to_use(cryptotext, key, source_path) f = Fernet(key) - return f.decrypt(cryptotext).decode('utf-8') + if binary: + return f.decrypt(cryptotext) + else: + return f.decrypt(cryptotext).decode('utf-8') def _decrypt_file_as_base64(self, source_path=None, key=None): """ @@ -237,26 +240,27 @@ return random(h.digest()) def _load_keys(self): - config = ConfigParser() - secrets_file = join(self.repo.path, FILENAME_SECRETS) - try: - config.read(secrets_file) - except IOError: - io.debug(_("unable to read {}").format(secrets_file)) - return {} - result = {} - for section in config.sections(): + with io.job(_("Loading secret keys")): + config = ConfigParser() + secrets_file = join(self.repo.path, FILENAME_SECRETS) try: - result[section] = config.get(section, 'key').encode('utf-8') - except NoOptionError: - result[section] = run( - config.get(section, 'key_command'), - check=True, - shell=True, - stdout=PIPE, # replace with capture_output=True - # when dropping support for Python 3.6 - ).stdout.strip() - return result + config.read(secrets_file) + except IOError: + io.debug(_("unable to read {}").format(secrets_file)) + return {} + result = {} + for section in config.sections(): + try: + result[section] = config.get(section, 'key').encode('utf-8') + except NoOptionError: + result[section] = run( + config.get(section, 'key_command'), + check=True, + shell=True, + stdout=PIPE, # replace with capture_output=True + # when dropping support for Python 3.6 + ).stdout.strip() + return result @staticmethod def cmd(cmdline, as_text=True, strip=True): @@ -287,11 +291,12 @@ key=key, ) - def decrypt_file(self, source_path, key=None): + def decrypt_file(self, source_path, binary=False, key=None): return Fault( 'bw secrets decrypt_file', self._decrypt_file, source_path=source_path, + binary=binary, key=key, ) diff -Nru bundlewrap-4.17.2/bundlewrap/utils/__init__.py bundlewrap-4.18.0/bundlewrap/utils/__init__.py --- bundlewrap-4.17.2/bundlewrap/utils/__init__.py 2023-05-05 16:24:17.000000000 +0000 +++ bundlewrap-4.18.0/bundlewrap/utils/__init__.py 2024-04-02 00:31:45.000000000 +0000 @@ -59,14 +59,14 @@ return cached_property(prop, convert_to=set) -def download(url, path): +def download(url, path, timeout=60.0): with error_context(url=url, path=path): if not exists(dirname(path)): makedirs(dirname(path)) if exists(path): chmod(path, MODE644) with open(path, 'wb') as f: - r = get(url, stream=True) + r = get(url, stream=True, timeout=timeout) r.raise_for_status() for block in r.iter_content(1024): if not block: diff -Nru bundlewrap-4.17.2/bundlewrap/utils/metastack.py bundlewrap-4.18.0/bundlewrap/utils/metastack.py --- bundlewrap-4.17.2/bundlewrap/utils/metastack.py 2023-05-05 16:24:17.000000000 +0000 +++ bundlewrap-4.18.0/bundlewrap/utils/metastack.py 2024-04-02 00:31:45.000000000 +0000 @@ -1,6 +1,3 @@ -from collections import OrderedDict -from sys import version_info - from ..metadata import METADATA_TYPES, deepcopy_metadata, validate_metadata, value_at_key_path from .dicts import ATOMIC_TYPES, map_dict_keys, merge_dict @@ -20,9 +17,9 @@ def __init__(self): self._partitions = ( # We rely heavily on insertion order in these dicts. - {} if version_info >= (3, 7) else OrderedDict(), # node/groups - {} if version_info >= (3, 7) else OrderedDict(), # reactors - {} if version_info >= (3, 7) else OrderedDict(), # defaults + {}, # node/groups + {}, # reactors + {}, # defaults ) self._cached_partitions = {} diff -Nru bundlewrap-4.17.2/bundlewrap/utils/ui.py bundlewrap-4.18.0/bundlewrap/utils/ui.py --- bundlewrap-4.17.2/bundlewrap/utils/ui.py 2023-05-05 16:24:17.000000000 +0000 +++ bundlewrap-4.18.0/bundlewrap/utils/ui.py 2024-04-02 00:31:45.000000000 +0000 @@ -1,5 +1,5 @@ from contextlib import contextmanager, suppress -from datetime import datetime +from datetime import datetime, timedelta import faulthandler from functools import wraps from os import _exit, environ, getpid, kill @@ -157,6 +157,43 @@ termios.tcflush(sys.stdin, termios.TCIFLUSH) +class JobManager: + def __init__(self): + self._jobs = [] + + def add(self, msg): + job_id = (time(), msg) + self._jobs.append(job_id) + return job_id + + def remove(self, job_id): + self._jobs.remove(job_id) + + @property + def current_job(self): + try: + job_start, job_msg = self._jobs[-1] + except IndexError: + return None + current_time = time() + if current_time - job_start > 3.0: + # If the latest job is taking a long time, start rotating + # the displayed job every 3s. That way, users can see all + # long-running jobs currently in progress. + index = int(current_time / 3.0) % len(self._jobs) + job_start, job_msg = self._jobs[index] + + elapsed = current_time - job_start + if elapsed > 10.0: + job_msg += " ({})".format(format_duration(timedelta(seconds=elapsed))) + + return job_msg + + @property + def messages(self): + return [job_msg for job_start, job_msg in self._jobs] + + class IOManager: """ Threadsafe singleton class that handles all IO. @@ -165,7 +202,7 @@ self._active = False self.debug_log_file = None self.debug_mode = False - self.jobs = [] + self.jobs = JobManager() self.lock = Lock() self.progress = 0 self.progress_start = None @@ -267,15 +304,16 @@ return with self.lock: self._clear_last_job() - self.jobs.append(msg) + job_id = self.jobs.add(msg) self._write_current_job() + return job_id - def job_del(self, msg): + def job_del(self, job_id): if not self._active: return with self.lock: self._clear_last_job() - self.jobs.remove(msg) + self.jobs.remove(job_id) self._write_current_job() def progress_advance(self, increment=1): @@ -295,9 +333,9 @@ if INFO_EVENT.is_set(): INFO_EVENT.clear() table = [] - if self.jobs: - table.append([bold(_("Running jobs")), self.jobs[0].strip()]) - for job in self.jobs[1:]: + if self.jobs.messages: + table.append([bold(_("Running jobs")), self.jobs.messages[0].strip()]) + for job in self.jobs.messages[1:]: table.append(["", job.strip()]) try: progress = (self.progress / float(self.progress_total)) @@ -342,11 +380,11 @@ @contextmanager def job(self, job_text): - self.job_add(job_text) + job_id = self.job_add(job_text) try: yield finally: - self.job_del(job_text) + self.job_del(job_id) def job_wrapper(self, job_text): def outer_wrapper(wrapped_function): @@ -411,7 +449,8 @@ self._write_current_job() def _write_current_job(self): - if self.jobs and TTY: + current_job = self.jobs.current_job + if current_job and TTY: line = "{} ".format(blue(self._spinner_character())) try: progress = (self.progress / float(self.progress_total)) @@ -420,7 +459,7 @@ else: progress_text = "{:.1f}% ".format(progress * 100) line += bold(progress_text) - line += self.jobs[-1] + line += current_job write_to_stream( STDOUT_WRITER, trim_visible_len_to(line, get_terminal_size().columns), diff -Nru bundlewrap-4.17.2/debian/changelog bundlewrap-4.18.0/debian/changelog --- bundlewrap-4.17.2/debian/changelog 2023-08-24 11:30:11.000000000 +0000 +++ bundlewrap-4.18.0/debian/changelog 2024-04-23 12:00:29.000000000 +0000 @@ -1,3 +1,9 @@ +bundlewrap (4.18.0-1) unstable; urgency=medium + + * New upstream release + + -- Jonathan Carter Tue, 23 Apr 2024 14:00:29 +0200 + bundlewrap (4.17.2-1) unstable; urgency=medium * New upstream release diff -Nru bundlewrap-4.17.2/docs/content/guide/api.md bundlewrap-4.18.0/docs/content/guide/api.md --- bundlewrap-4.17.2/docs/content/guide/api.md 2023-05-05 16:24:17.000000000 +0000 +++ bundlewrap-4.18.0/docs/content/guide/api.md 2024-04-02 00:31:45.000000000 +0000 @@ -193,6 +193,8 @@ - `command` What should be executed on the node - `may_fail` If `False`, `bundlewrap.exceptions.RemoteException` will be raised if the command does not return 0. +`bundlewrap.exceptions.TransportException` will be raised if there was a transport error while running the command, e.g. the SSH process died unexpectedly. +
**`.upload(local_path, remote_path, mode=None, owner="", group="")`** diff -Nru bundlewrap-4.17.2/docs/content/items/file.md bundlewrap-4.18.0/docs/content/items/file.md --- bundlewrap-4.17.2/docs/content/items/file.md 2023-05-05 16:24:17.000000000 +0000 +++ bundlewrap-4.18.0/docs/content/items/file.md 2024-04-02 00:31:45.000000000 +0000 @@ -56,6 +56,12 @@
+## download_timeout + +Only valid if `content_type` is set to `download`. This value can be set to a number of seconds after which an error is thrown if the remote server no longer provides a response. This does NOT limit the total duration of the download. Defaults to `60.0`. + +
+ ## encoding Encoding of the target file. Note that this applies to the remote file only, your template is still conveniently written in UTF-8 and will be converted by BundleWrap. Defaults to "utf-8". Other possible values (e.g. "latin-1") can be found [here](http://docs.python.org/2/library/codecs.html#standard-encodings). Only allowed with `content_type` `jinja2`, `mako`, or `text`. diff -Nru bundlewrap-4.17.2/setup.py bundlewrap-4.18.0/setup.py --- bundlewrap-4.17.2/setup.py 2023-05-05 16:24:17.000000000 +0000 +++ bundlewrap-4.18.0/setup.py 2024-04-02 00:31:45.000000000 +0000 @@ -3,7 +3,7 @@ setup( name="bundlewrap", - version="4.17.2", + version="4.18.0", description="Config management with Python", long_description=( "By allowing for easy and low-overhead config management, BundleWrap fills the gap between complex deployments using Chef or Puppet and old school system administration over SSH.\n" @@ -28,24 +28,25 @@ "Natural Language :: English", "Operating System :: POSIX :: Linux", "Programming Language :: Python", - "Programming Language :: Python :: 3.7", - "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.8", # remove hack in files.py import when EOL "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", "Topic :: System :: Installation/Setup", "Topic :: System :: Systems Administration", ], install_requires=[ "cryptography", "Jinja2", + "librouteros >= 3.0.0", "Mako", "passlib", "pyyaml", "requests >= 1.0.0", - "librouteros >= 3.0.0", - "tomlkit", "rtoml ; python_version<'3.11'", + "setuptools", + "tomlkit", ], zip_safe=False, ) diff -Nru bundlewrap-4.17.2/tests/integration/bw_apply_actions.py bundlewrap-4.18.0/tests/integration/bw_apply_actions.py --- bundlewrap-4.17.2/tests/integration/bw_apply_actions.py 2023-05-05 16:24:17.000000000 +0000 +++ bundlewrap-4.18.0/tests/integration/bw_apply_actions.py 2024-04-02 00:31:45.000000000 +0000 @@ -22,7 +22,8 @@ }, }, ) - run("bw apply localhost", path=str(tmpdir)) + stdout, stderr, rcode = run("bw apply localhost", path=str(tmpdir)) + assert rcode == 0 def test_action_fail(tmpdir): @@ -46,7 +47,8 @@ }, }, ) - run("bw apply localhost", path=str(tmpdir)) + stdout, stderr, rcode = run("bw apply localhost", path=str(tmpdir)) + assert rcode == 1 def test_action_pipe_binary(tmpdir): @@ -72,7 +74,8 @@ }, }, ) - run("bw apply localhost", path=str(tmpdir)) + stdout, stderr, rcode = run("bw apply localhost", path=str(tmpdir)) + assert rcode == 0 def test_action_pipe_utf8(tmpdir): @@ -98,7 +101,8 @@ }, }, ) - run("bw apply localhost", path=str(tmpdir)) + stdout, stderr, rcode = run("bw apply localhost", path=str(tmpdir)) + assert rcode == 0 def test_action_return_codes(tmpdir): @@ -135,4 +139,5 @@ }, }, ) - run("bw apply localhost", path=str(tmpdir)) + stdout, stderr, rcode = run("bw apply localhost", path=str(tmpdir)) + assert rcode == 0 diff -Nru bundlewrap-4.17.2/tests/integration/bw_apply_autoonly.py bundlewrap-4.18.0/tests/integration/bw_apply_autoonly.py --- bundlewrap-4.17.2/tests/integration/bw_apply_autoonly.py 2023-05-05 16:24:17.000000000 +0000 +++ bundlewrap-4.18.0/tests/integration/bw_apply_autoonly.py 2024-04-02 00:31:45.000000000 +0000 @@ -38,7 +38,8 @@ }, ) - run("bw apply -o bundle:test -- localhost", path=str(tmpdir)) + stdout, stderr, rcode = run("bw apply -o bundle:test -- localhost", path=str(tmpdir)) + assert rcode == 0 assert exists(join(str(tmpdir), "foo")) assert exists(join(str(tmpdir), "bar")) assert not exists(join(str(tmpdir), "baz")) diff -Nru bundlewrap-4.17.2/tests/integration/bw_apply_directories.py bundlewrap-4.18.0/tests/integration/bw_apply_directories.py --- bundlewrap-4.17.2/tests/integration/bw_apply_directories.py 2023-05-05 16:24:17.000000000 +0000 +++ bundlewrap-4.18.0/tests/integration/bw_apply_directories.py 2024-04-02 00:31:45.000000000 +0000 @@ -43,7 +43,8 @@ with open(join(str(tmpdir), "purgedir", "subdir3", "unmanaged_file"), 'w') as f: f.write("content") - run("bw apply localhost", path=str(tmpdir)) + stdout, stderr, rcode = run("bw apply localhost", path=str(tmpdir)) + assert rcode == 0 assert not exists(join(str(tmpdir), "purgedir", "unmanaged_file")) assert not exists(join(str(tmpdir), "purgedir", "subdir3", "unmanaged_file")) @@ -91,7 +92,8 @@ with open(join(str(tmpdir), "purgedir", "unmanaged_:'_file"), 'w') as f: f.write("content") - run("bw apply localhost", path=str(tmpdir)) + stdout, stderr, rcode = run("bw apply localhost", path=str(tmpdir)) + assert rcode == 0 assert not exists(join(str(tmpdir), "purgedir", "unmänäged_file")) assert not exists(join(str(tmpdir), "purgedir", "unmanaged_`uname`_file")) diff -Nru bundlewrap-4.17.2/tests/integration/bw_apply_files.py bundlewrap-4.18.0/tests/integration/bw_apply_files.py --- bundlewrap-4.17.2/tests/integration/bw_apply_files.py 2023-05-05 16:24:17.000000000 +0000 +++ bundlewrap-4.18.0/tests/integration/bw_apply_files.py 2024-04-02 00:31:45.000000000 +0000 @@ -26,7 +26,9 @@ }, ) - run("bw apply localhost", path=str(tmpdir)) + stdout, stderr, rcode = run("bw apply localhost", path=str(tmpdir)) + assert rcode == 0 + with open(join(str(tmpdir), "foo"), 'rb') as f: content = f.read() assert content == b"" @@ -56,7 +58,9 @@ with open(join(str(tmpdir), "foo"), 'wb') as f: f.write(b"existing content") - run("bw apply localhost", path=str(tmpdir)) + stdout, stderr, rcode = run("bw apply localhost", path=str(tmpdir)) + assert rcode == 0 + with open(join(str(tmpdir), "foo"), 'rb') as f: content = f.read() assert content == b"existing content" @@ -84,7 +88,10 @@ }, }, ) - run("bw apply localhost", path=str(tmpdir)) + + stdout, stderr, rcode = run("bw apply localhost", path=str(tmpdir)) + assert rcode == 0 + with open(join(str(tmpdir), "foo.bin"), 'rb') as f: content = f.read() assert content.decode('latin-1') == "ö" @@ -114,7 +121,9 @@ with open(join(str(tmpdir), "bundles", "test", "files", "foo.bin"), 'wb') as f: f.write("ö".encode('utf-8')) - run("bw apply localhost", path=str(tmpdir)) + stdout, stderr, rcode = run("bw apply localhost", path=str(tmpdir)) + assert rcode == 0 + with open(join(str(tmpdir), "foo.bin"), 'rb') as f: content = f.read() assert content.decode('latin-1') == "ö" @@ -143,7 +152,8 @@ }, }, ) - run("bw apply localhost", path=str(tmpdir)) + stdout, stderr, rcode = run("bw apply localhost", path=str(tmpdir)) + assert rcode == 0 assert not exists(join(str(tmpdir), "foo")) @@ -169,7 +179,8 @@ }, }, ) - run("bw apply localhost", path=str(tmpdir)) + stdout, stderr, rcode = run("bw apply localhost", path=str(tmpdir)) + assert rcode == 0 with open(join(str(tmpdir), "foo"), 'rb') as f: content = f.read() assert content == b"localhost" @@ -197,7 +208,10 @@ }, }, ) - run("bw apply localhost", path=str(tmpdir)) + + stdout, stderr, rcode = run("bw apply localhost", path=str(tmpdir)) + assert rcode == 0 + with open(join(str(tmpdir), "foo"), 'rb') as f: content = f.read() assert content == b"faCTT76kagtDuZE5wnoiD1CxhGKmbgiX" @@ -225,7 +239,10 @@ }, }, ) - run("bw apply localhost", path=str(tmpdir)) + + stdout, stderr, rcode = run("bw apply localhost", path=str(tmpdir)) + assert rcode == 0 + with open(join(str(tmpdir), "foo"), 'rb') as f: content = f.read() assert content == b"${node.name}" diff -Nru bundlewrap-4.17.2/tests/integration/bw_apply_precedes.py bundlewrap-4.18.0/tests/integration/bw_apply_precedes.py --- bundlewrap-4.17.2/tests/integration/bw_apply_precedes.py 2023-05-05 16:24:17.000000000 +0000 +++ bundlewrap-4.18.0/tests/integration/bw_apply_precedes.py 2024-04-02 00:31:45.000000000 +0000 @@ -37,7 +37,10 @@ }, }, ) - run("bw apply localhost", path=str(tmpdir)) + + stdout, stderr, rcode = run("bw apply localhost", path=str(tmpdir)) + assert rcode == 0 + with open(join(str(tmpdir), "file")) as f: content = f.read() assert content == "1\n2\n3\n" @@ -78,7 +81,10 @@ }, }, ) - run("bw apply localhost", path=str(tmpdir)) + + stdout, stderr, rcode = run("bw apply localhost", path=str(tmpdir)) + assert rcode == 0 + with open(join(str(tmpdir), "file")) as f: content = f.read() assert content == "1\n3\n" @@ -120,7 +126,8 @@ }, }, ) - run("bw apply localhost", path=str(tmpdir)) + stdout, stderr, rcode = run("bw apply localhost", path=str(tmpdir)) + assert rcode == 0 assert not exists(join(str(tmpdir), "file")) @@ -159,7 +166,10 @@ }, }, ) - run("bw apply localhost", path=str(tmpdir)) + + stdout, stderr, rcode = run("bw apply localhost", path=str(tmpdir)) + assert rcode == 0 + with open(join(str(tmpdir), "file")) as f: content = f.read() assert content == "2\n3\n" @@ -198,7 +208,10 @@ }, }, ) - run("bw apply localhost", path=str(tmpdir)) + + stdout, stderr, rcode = run("bw apply localhost", path=str(tmpdir)) + assert rcode == 1 # because of action2 + with open(join(str(tmpdir), "file")) as f: content = f.read() assert content == "1\n" @@ -230,7 +243,10 @@ }, }, ) - run("bw apply localhost", path=str(tmpdir)) + + stdout, stderr, rcode = run("bw apply localhost", path=str(tmpdir)) + assert rcode == 0 + with open(join(str(tmpdir), "file")) as f: content = f.read() assert content == "1\n2\n" diff -Nru bundlewrap-4.17.2/tests/integration/bw_apply_secrets.py bundlewrap-4.18.0/tests/integration/bw_apply_secrets.py --- bundlewrap-4.17.2/tests/integration/bw_apply_secrets.py 2023-05-05 16:24:17.000000000 +0000 +++ bundlewrap-4.18.0/tests/integration/bw_apply_secrets.py 2024-04-02 00:31:45.000000000 +0000 @@ -26,7 +26,10 @@ }} """.format(join(str(tmpdir), "secret"))) - run("bw apply localhost", path=str(tmpdir)) + + stdout, stderr, rcode = run("bw apply localhost", path=str(tmpdir)) + assert rcode == 0 + with open(join(str(tmpdir), "secret")) as f: content = f.read() assert content == "sQDdTXu5OmCki8gdGgYdfTxooevckXcB" @@ -55,7 +58,10 @@ }, ) - run("bw apply localhost", path=str(tmpdir)) + + stdout, stderr, rcode = run("bw apply localhost", path=str(tmpdir)) + assert rcode == 0 + with open(join(str(tmpdir), "secret")) as f: content = f.read() assert content == "sQDdTXu5OmCki8gdGgYdfTxooevckXcB" @@ -89,7 +95,10 @@ }} """.format(host_os())) - run("bw apply localhost", path=str(tmpdir)) + + stdout, stderr, rcode = run("bw apply localhost", path=str(tmpdir)) + assert rcode == 0 + with open(join(str(tmpdir), "secret")) as f: content = f.read() assert content == "sQDdTXu5OmCki8gdGgYdfTxooevckXcB" @@ -118,7 +127,10 @@ }, ) - run("bw apply localhost", path=str(tmpdir)) + + stdout, stderr, rcode = run("bw apply localhost", path=str(tmpdir)) + assert rcode == 0 + with open(join(str(tmpdir), "secret")) as f: content = f.read() assert content == "sQDdTXu5OmCki8gdGgYdfTxooevckXcB" diff -Nru bundlewrap-4.17.2/tests/integration/bw_hash.py bundlewrap-4.18.0/tests/integration/bw_hash.py --- bundlewrap-4.17.2/tests/integration/bw_hash.py 2023-05-05 16:24:17.000000000 +0000 +++ bundlewrap-4.18.0/tests/integration/bw_hash.py 2024-04-02 00:31:45.000000000 +0000 @@ -8,6 +8,7 @@ stdout, stderr, rcode = run("bw hash", path=str(tmpdir)) assert stdout == b"bf21a9e8fbc5a3846fb05b4fa0859e0917b2202f\n" assert stderr == b"" + assert rcode == 0 def test_nondeterministic(tmpdir): @@ -36,6 +37,7 @@ for i in range(3): stdout, stderr, rcode = run("bw hash", path=str(tmpdir)) + assert rcode == 0 hashes.add(stdout.strip()) assert len(hashes) > 1 @@ -68,6 +70,7 @@ for i in range(3): stdout, stderr, rcode = run("bw hash", path=str(tmpdir)) + assert rcode == 0 hashes.add(stdout.strip()) assert len(hashes) == 1 diff -Nru bundlewrap-4.17.2/tests/integration/bw_metadata.py bundlewrap-4.18.0/tests/integration/bw_metadata.py --- bundlewrap-4.17.2/tests/integration/bw_metadata.py 2023-05-05 16:24:17.000000000 +0000 +++ bundlewrap-4.18.0/tests/integration/bw_metadata.py 2024-04-02 00:31:45.000000000 +0000 @@ -464,6 +464,7 @@ "broken": True, "again": True, } + assert rcode == 0 def test_own_node_metadata(tmpdir): @@ -489,6 +490,7 @@ "number": 47, "plusone": 48, } + assert rcode == 0 def test_other_node_metadata(tmpdir): @@ -522,16 +524,19 @@ return {'other_numbers': numbers} """) stdout, stderr, rcode = run("bw metadata node1", path=str(tmpdir)) + assert rcode == 0 assert loads(stdout.decode()) == { "number": 47, "other_numbers": [23, 42], } stdout, stderr, rcode = run("bw metadata node2", path=str(tmpdir)) + assert rcode == 0 assert loads(stdout.decode()) == { "number": 42, "other_numbers": [23, 47], } stdout, stderr, rcode = run("bw metadata node3", path=str(tmpdir)) + assert rcode == 0 assert loads(stdout.decode()) == { "number": 23, "other_numbers": [42, 47], diff -Nru bundlewrap-4.17.2/tests/integration/bw_pw.py bundlewrap-4.18.0/tests/integration/bw_pw.py --- bundlewrap-4.17.2/tests/integration/bw_pw.py 2023-05-05 16:24:17.000000000 +0000 +++ bundlewrap-4.18.0/tests/integration/bw_pw.py 2024-04-02 00:31:45.000000000 +0000 @@ -80,6 +80,31 @@ assert f.read() == "ohai" +def test_encrypt_file_binary(tmpdir): + make_repo(tmpdir) + + source_file = join(str(tmpdir), "data", "source") + with open(source_file, 'wb') as f: + f.write(b"\000\001\002") + + stdout, stderr, rcode = run( + f"bw pw -e -f encrypted \"{source_file}\"", + path=str(tmpdir), + ) + assert stderr == b"" + assert rcode == 0 + + stdout, stderr, rcode = run( + "bw pw -d -f decrypted encrypted", + path=str(tmpdir), + ) + assert stdout == b"" + assert stderr == b"" + assert rcode == 0 + with open(join(tmpdir, "data", "decrypted"), 'rb') as f: + assert f.read() == b"\000\001\002" + + def test_human_password(tmpdir): make_repo(tmpdir) diff -Nru bundlewrap-4.17.2/tests/integration/bw_stats.py bundlewrap-4.18.0/tests/integration/bw_stats.py --- bundlewrap-4.17.2/tests/integration/bw_stats.py 2023-05-05 16:24:17.000000000 +0000 +++ bundlewrap-4.18.0/tests/integration/bw_stats.py 2024-04-02 00:31:45.000000000 +0000 @@ -39,3 +39,4 @@ │ 2 │ file │ ╰───────┴───────────────────╯ """.encode('utf-8') + assert rcode == 0 diff -Nru bundlewrap-4.17.2/tests/integration/bw_test.py bundlewrap-4.18.0/tests/integration/bw_test.py --- bundlewrap-4.17.2/tests/integration/bw_test.py 2023-05-05 16:24:17.000000000 +0000 +++ bundlewrap-4.18.0/tests/integration/bw_test.py 2024-04-02 00:31:45.000000000 +0000 @@ -38,8 +38,13 @@ def test_node(repo, node, **kwargs): io.stdout("BBB") """) - assert b"AAA" in run("bw test -H", path=str(tmpdir))[0] - assert b"BBB" in run("bw test -J", path=str(tmpdir))[0] + stdout, stderr, rcode = run("bw test -H", path=str(tmpdir)) + assert rcode == 0 + assert b"AAA" in stdout + + stdout, stderr, rcode = run("bw test -J", path=str(tmpdir)) + assert rcode == 0 + assert b"BBB" in stdout def test_circular_dep_direct(tmpdir):