diff --git a/AGENTS.md b/AGENTS.md index f8e0421..2f54d57 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -107,6 +107,7 @@ suave --help # every command, with examples; suave COMMAND suave --dry-run ... # print the commands instead of running them suave docker build [--all] [--tag T] # suave-headless:latest (+ Kasm images with --all) suave docker run|shell|stop [--rm]|status # host only; container name from container_name +suave docker mount add PATH [--to DEST]|remove PATH|list # extra_mounts for docker run (--recreate to apply) suave build [PKG ...] [--clean] # colcon build (default: all SUAVE packages) suave test [PKG ...] [--no-build] [--lint] [-k EXPR] # non-zero exit if any package failed suave run [--config FILE] [--seed N] [-p KEY:=VALUE] # suave_runner campaign diff --git a/CLAUDE.md b/CLAUDE.md index 815bc17..95552ec 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -148,6 +148,7 @@ suave --help # every command, with examples; suave COMMAND suave --dry-run ... # print the commands instead of running them suave docker build [--all] [--tag T] # suave-headless:latest (+ Kasm images with --all) suave docker run|shell|stop [--rm]|status # host only; container name from container_name +suave docker mount add PATH [--to DEST]|remove PATH|list # extra_mounts for docker run (--recreate to apply) suave build [PKG ...] [--clean] # colcon build (default: all SUAVE packages) suave test [PKG ...] [--no-build] [--lint] [-k EXPR] # non-zero exit if any package failed suave run [--config FILE] [--seed N] [-p KEY:=VALUE] # suave_runner campaign diff --git a/docs/source/cli.md b/docs/source/cli.md index 712e98d..fe8eea4 100644 --- a/docs/source/cli.md +++ b/docs/source/cli.md @@ -16,6 +16,7 @@ package adds `suave` to `PATH`), or symlink `suave_cli/bin/suave` into a folder suave docker build # build suave-headless:latest (--all adds the GUI images) suave docker run # start the 'suave' container in the background suave docker shell # shell inside it, ROS sourced + suave docker mount add ../my_package # mount another package into the container's src/ suave test suave_runner # build and test one package suave run --config my_runner.yml # run a campaign suave batch start # run the campaigns in batch_campaigns.yml diff --git a/suave_cli/README.md b/suave_cli/README.md index 14a4805..a0bbd4d 100644 --- a/suave_cli/README.md +++ b/suave_cli/README.md @@ -16,6 +16,7 @@ Inside the SUAVE images `suave` is already on `PATH`. suave docker build # suave-headless:latest suave docker run # background container 'suave', checkout + ~/suave/results mounted + suave docker mount add ../my_package # also mount it at the container's workspace src/ suave test suave_runner # build + test in the container suave run # experiment runner with the installed runner_config.yml suave batch resume --latest @@ -45,12 +46,21 @@ repeats it. Precedence: flag > environment variable > config file > default. | run_mode | detached | `--detach`, `--interactive` | | mount_src, mount_results | true | `--[no-]mount-src`, `--[no-]mount-results` | | host_results_dir | ~/suave/results | `--results-dir` | +| extra_mounts | none | `suave docker mount add\|remove\|list`; `--mount`, `--no-extra-mounts` on `docker run` | | host_workspace | auto-detect | `--workspace` / `SUAVE_WORKSPACE` | | ros_setup | /opt/ros/humble/setup.bash | | | container_results_dir, container_src_dir, container_workspace | headless image paths | | `suave config show` prints every value and where it came from. +`extra_mounts` is a list of `HOST:CONTAINER` bind mounts added after the checkout and results +mounts. `suave docker mount add PATH` stores PATH as an absolute path and mounts it at +`/src/` unless `--to CONTAINER` is given; it refuses a missing +PATH and a destination that overlaps another mount. `suave docker run --mount HOST[:CONTAINER]` +adds a mount for one run only. Mounts apply when the container is created, so run +`suave docker run --recreate` after changing them, then `suave build ` for new +ROS packages. + ## Development suave self-test # unit tests + linters, no colcon diff --git a/suave_cli/suave_cli/config.py b/suave_cli/suave_cli/config.py index f77a0c5..2a58e59 100644 --- a/suave_cli/suave_cli/config.py +++ b/suave_cli/suave_cli/config.py @@ -18,7 +18,8 @@ import configparser from dataclasses import dataclass import os -from pathlib import Path +from pathlib import Path, PurePosixPath +import re import tempfile from suave_cli import term @@ -53,6 +54,8 @@ class KeySpec: 'mount_results': KeySpec('true', 'mount the results folder into the container', kind='bool'), 'host_results_dir': KeySpec('~/suave/results', 'results folder on this machine'), + 'extra_mounts': KeySpec('', 'additional HOST:CONTAINER bind mounts for suave docker run', + kind='list'), 'host_workspace': KeySpec('', 'colcon workspace for local runs (empty = auto-detect)', env='SUAVE_WORKSPACE'), 'ros_setup': KeySpec('/opt/ros/humble/setup.bash', 'ROS setup script for local runs'), @@ -99,12 +102,28 @@ def normalize(key, value, source): if lowered in FALSE_WORDS: return 'false' raise CliError(f'{key}: expected true or false, got {value!r} (from {source})') + if spec.kind == 'list': + return '\n'.join(_normalize_mount(key, item, source) for item in split_list(value)) if spec.choices and value not in spec.choices: raise CliError( f'{key}: expected one of {", ".join(spec.choices)}, got {value!r} (from {source})') return value +def split_list(value): + """Return the non-empty entries of a newline- or comma-separated list value.""" + return [item.strip() for item in re.split(r'[\n,]', value) if item.strip()] + + +def _normalize_mount(key, item, source): + host, sep, dest = item.partition(':') + if not sep or ':' in dest or not host or not dest: + raise CliError(f'{key}: expected HOST:CONTAINER, got {item!r} (from {source})') + if not Path(host).is_absolute() or not PurePosixPath(dest).is_absolute(): + raise CliError(f'{key}: both paths must be absolute, got {item!r} (from {source})') + return f'{host}:{dest}' + + def load_file(path): """Return the [suave] section of path as a dict, or {} when it is missing.""" parser = configparser.ConfigParser(interpolation=None) @@ -159,6 +178,10 @@ def get_bool(self, key): """Return the value of a boolean key.""" return self._values[key] == 'true' + def get_list(self, key): + """Return the entries of a list key.""" + return split_list(self._values[key]) + def source(self, key): """Return where the value of key came from.""" return self._sources[key] @@ -242,6 +265,8 @@ def cmd_show(ctx): print(f'config file: {ctx.config_path} ({state})') width = max(len(key) for key in KEYS) for key, value, source in ctx.settings.items(): + if KEYS[key].kind == 'list': + value = ', '.join(split_list(value)) print(f'{key:<{width}} = {value:<36} ({source})') return 0 diff --git a/suave_cli/suave_cli/container.py b/suave_cli/suave_cli/container.py index be85faa..5b9c48a 100644 --- a/suave_cli/suave_cli/container.py +++ b/suave_cli/suave_cli/container.py @@ -15,10 +15,10 @@ """Start, reuse, enter, stop and inspect the SUAVE container.""" import argparse -from pathlib import Path +from pathlib import Path, PurePosixPath -from suave_cli import term -from suave_cli.docker_util import container_state, require_docker +from suave_cli import config, term +from suave_cli.docker_util import container_state, docker_available, require_docker, require_host from suave_cli.errors import CliError from suave_cli.images import ensure_image from suave_cli.targets import CONTAINER_ROS_SETUP, Target @@ -35,7 +35,8 @@ 4. ghcr.io/kas-lab/suave-headless:main (pulled after confirmation) By default this checkout is mounted at the container's src/suave and -~/suave/results at the container's results folder. +~/suave/results at the container's results folder, plus every mount +saved with 'suave docker mount add'. """ RUN_EPILOG = """\ @@ -43,12 +44,24 @@ suave docker run detached, default mounts suave docker run --interactive --no-mount-src throwaway shell on the baked-in code suave docker run --image suave-headless:exp1 --recreate + suave docker run --mount ../my_package one-off extra mount into the workspace src/ suave docker run -- --network host extra docker run arguments """ -def build_mounts(ctx): - """Return the (host path, container path) bind mounts for docker run.""" +MOUNT_EPILOG = """\ +examples: + suave docker mount add ../my_package mount at /src/my_package + suave docker mount add ~/data --to /data + suave docker mount remove ../my_package by host path or container path + suave docker mount list + +Mounts only apply when the container is created: run 'suave docker run --recreate'. +""" + + +def default_mounts(ctx): + """Return the checkout and results bind mounts for docker run.""" settings, args = ctx.settings, ctx.args mounts = [] src = getattr(args, 'src', None) @@ -61,6 +74,55 @@ def build_mounts(ctx): return mounts +def parse_mount(ctx, host, dest=None): + """Return a (host, container) mount for host; dest defaults to the workspace src/.""" + host_path = Path(host).expanduser().resolve() + if not dest: + workspace = PurePosixPath(ctx.settings.get('container_workspace')) + dest = str(workspace / 'src' / host_path.name) + entry = config.normalize('extra_mounts', f'{host_path}:{dest}', 'command line') + return tuple(entry.split(':', 1)) + + +def saved_mounts(entries): + """Return (host, container) pairs for normalized extra_mounts entries.""" + return [tuple(entry.split(':', 1)) for entry in entries] + + +def extra_mounts(ctx): + """Return the saved extra mounts and the --mount ones for docker run.""" + args = ctx.args + mounts = [] + if not getattr(args, 'no_extra_mounts', False): + mounts += saved_mounts(ctx.settings.get_list('extra_mounts')) + for spec in getattr(args, 'extra_mount', None) or []: + host, _, dest = spec.partition(':') + mounts.append(parse_mount(ctx, host, dest)) + return mounts + + +def build_mounts(ctx): + """Return the (host path, container path) bind mounts for docker run.""" + return default_mounts(ctx) + extra_mounts(ctx) + + +def check_mount(host, dest, taken): + """Refuse a missing host path or a destination that overlaps a path in taken.""" + if not Path(host).exists(): + raise CliError(f'mount source {host} does not exist') + for other in taken: + if PurePosixPath(dest).is_relative_to(other) or PurePosixPath(other).is_relative_to(dest): + raise CliError(f'mount destination {dest} overlaps {other}; choose another with --to') + + +def check_extra_mounts(defaults, extras): + """Validate every extra mount against the default mounts and the ones before it.""" + taken = [dest for _, dest in defaults] + for host, dest in extras: + check_mount(host, dest, taken) + taken.append(dest) + + def run_argv(name, image, mode, mounts, extra): """Return the docker run command line.""" argv = ['docker', 'run'] @@ -93,7 +155,9 @@ def cmd_run(ctx): require_docker(ctx) settings, executor = ctx.settings, ctx.executor name, mode = settings.get('container_name'), settings.get('run_mode') - mounts = build_mounts(ctx) + defaults, extras = default_mounts(ctx), extra_mounts(ctx) + check_extra_mounts(defaults, extras) + mounts = defaults + extras state = container_state(executor, name) if state and getattr(ctx.args, 'recreate', False): code = executor.run(['docker', 'rm', '-f', name]) @@ -114,6 +178,10 @@ def cmd_run(ctx): term.info("The source is mounted, but the image's build/ and install/ come from its " "baked-in copy: run 'suave build' after changing C++, messages or " 'entry points.') + workspace_src = PurePosixPath(settings.get('container_workspace')) / 'src' + for _, dest in extras: + if PurePosixPath(dest).parent == workspace_src: + term.info(f"{dest} is in the workspace: build it with 'suave build '.") code = executor.run(run_argv(name, image, mode, mounts, ctx.passthrough)) if code == 0 and mode == 'detached': term.info(f"container '{name}' started; open a shell with: suave docker shell") @@ -168,6 +236,76 @@ def cmd_status(ctx): return 0 +def _recreate_hint(ctx): + if not docker_available(ctx.executor): + return + name = ctx.settings.get('container_name') + if container_state(ctx.executor, name) is not None: + term.info(f"container '{name}' already exists; apply the change with: " + 'suave docker run --recreate') + + +def _save_mounts(ctx, entries): + values = config.load_file(ctx.config_path) + if entries: + values['extra_mounts'] = '\n'.join(entries) + else: + values.pop('extra_mounts', None) + return config.save_file(ctx.config_path, values) + + +def cmd_mount_add(ctx): + """Save an extra bind mount for suave docker run.""" + require_host(ctx) + host, dest = parse_mount(ctx, ctx.args.path, ctx.args.to) + entries = ctx.settings.get_list('extra_mounts') + entry = f'{host}:{dest}' + if entry in entries: + term.info(f'{host} is already mounted at {dest}') + return 0 + taken = [path for _, path in default_mounts(ctx) + saved_mounts(entries)] + check_mount(host, dest, taken) + if not _save_mounts(ctx, entries + [entry]): + return 1 + print(f'mount: {host} -> {dest} ({ctx.config_path})') + _recreate_hint(ctx) + return 0 + + +def cmd_mount_remove(ctx): + """Remove a saved extra bind mount, matched by host or container path.""" + require_host(ctx) + raw = ctx.args.path + host = str(Path(raw).expanduser().resolve()) + entries = config.split_list(config.load_file(ctx.config_path).get('extra_mounts', '')) + kept = [entry for entry in entries + if raw != entry and entry.partition(':')[0] != host and entry.partition(':')[2] != raw] + if len(kept) == len(entries): + saved = ', '.join(entries) or 'none' + raise CliError(f'no saved mount matches {raw!r}; saved mounts: {saved}') + if not _save_mounts(ctx, kept): + return 1 + for entry in entries: + if entry not in kept: + print(f'removed mount: {entry}') + if ctx.settings is not None: + _recreate_hint(ctx) + return 0 + + +def cmd_mount_list(ctx): + """Print the default and saved extra bind mounts.""" + require_host(ctx) + rows = [(host, dest, 'default') for host, dest in default_mounts(ctx)] + rows += [(host, dest, 'extra_mounts' + ('' if Path(host).exists() else ', missing')) + for host, dest in saved_mounts(ctx.settings.get_list('extra_mounts'))] + if not rows: + print('no mounts') + for host, dest, label in rows: + print(f'{host} -> {dest} ({label})') + return 0 + + def add_parsers(sub, common): """Add suave docker run, shell, stop and status.""" raw = argparse.RawDescriptionHelpFormatter @@ -195,6 +333,11 @@ def add_parsers(sub, common): default=argparse.SUPPRESS, help='do not mount the results folder') run.add_argument('--results-dir', metavar='PATH', default=argparse.SUPPRESS, help='host results folder to mount (default: ~/suave/results)') + run.add_argument('--mount', dest='extra_mount', action='append', metavar='HOST[:CONTAINER]', + help='extra bind mount for this run, added to the saved ones ' + '(default CONTAINER: /src/)') + run.add_argument('--no-extra-mounts', action='store_true', + help="skip the mounts saved with 'suave docker mount add'") run.add_argument('--recreate', action='store_true', help='remove an existing container with the same name first') run.set_defaults(func=cmd_run, accepts_passthrough=True) @@ -208,3 +351,20 @@ def add_parsers(sub, common): status = sub.add_parser('status', parents=[common], help='show whether the container exists, runs and what it mounts') status.set_defaults(func=cmd_status) + + mount = sub.add_parser('mount', parents=[common], help='manage extra bind mounts for run', + description='Save extra bind mounts in the extra_mounts setting. ' + 'Host only.', + epilog=MOUNT_EPILOG, formatter_class=raw) + mount.set_defaults(skip_first_run=True) + mount_sub = mount.add_subparsers(dest='mount_command', metavar='ACTION', required=True) + add = mount_sub.add_parser('add', parents=[common], help='save an extra mount') + add.add_argument('path', help='host file or folder to mount') + add.add_argument('--to', metavar='CONTAINER', + help='absolute container path (default: /src/)') + add.set_defaults(func=cmd_mount_add) + remove = mount_sub.add_parser('remove', parents=[common], help='remove a saved extra mount') + remove.add_argument('path', help='host path or container path of the mount') + remove.set_defaults(func=cmd_mount_remove, tolerate_bad_config=True) + show = mount_sub.add_parser('list', parents=[common], help='list default and extra mounts') + show.set_defaults(func=cmd_mount_list) diff --git a/suave_cli/suave_cli/docker_cmd.py b/suave_cli/suave_cli/docker_cmd.py index 64b2531..9bff0b4 100644 --- a/suave_cli/suave_cli/docker_cmd.py +++ b/suave_cli/suave_cli/docker_cmd.py @@ -23,6 +23,7 @@ suave docker build build suave-headless:latest suave docker run start or reuse the 'suave' container suave docker shell open a sourced shell in it + suave docker mount add ../my_pkg mount another package at the workspace src/ suave docker stop --rm stop and remove it """ diff --git a/suave_cli/suave_cli/main.py b/suave_cli/suave_cli/main.py index 939aacd..ec9359b 100644 --- a/suave_cli/suave_cli/main.py +++ b/suave_cli/suave_cli/main.py @@ -37,6 +37,7 @@ examples: suave docker build build suave-headless:latest suave docker run start the 'suave' container in the background + suave docker mount add PATH also mount PATH into the container's workspace src/ suave test suave_runner build and test one package suave batch resume --latest resume the most recent batch suave config show show settings and where they come from diff --git a/suave_cli/test/test_config.py b/suave_cli/test/test_config.py index a0dbe1e..53ba621 100644 --- a/suave_cli/test/test_config.py +++ b/suave_cli/test/test_config.py @@ -58,6 +58,27 @@ def test_invalid_values_name_their_source(): config.resolve({}, {}, {'mount_src': 'maybe'}) +def test_mount_lists_are_normalized(): + assert config.normalize('extra_mounts', '', 'test') == '' + value = config.normalize('extra_mounts', '\n/a:/b, /c d:/e\n', 'test') + assert value == '/a:/b\n/c d:/e' + assert config.resolve({}, {}, {'extra_mounts': value}).get_list('extra_mounts') == [ + '/a:/b', '/c d:/e'] + + +@pytest.mark.parametrize('entry', ['/a', '/a:/b:ro', 'rel:/b', '/a:rel', ':/b']) +def test_invalid_mount_entries(entry): + with pytest.raises(CliError, match='extra_mounts'): + config.normalize('extra_mounts', entry, 'test') + + +def test_mount_list_round_trips_through_file(tmp_path): + path = tmp_path / 'host.ini' + assert config.save_file(path, {'extra_mounts': '/a:/b\n/c:/d'}) + loaded = config.load_file(path) + assert config.resolve({}, {}, loaded).get_list('extra_mounts') == ['/a:/b', '/c:/d'] + + def test_unknown_file_key_warns(capsys): config.resolve({}, {}, {'colour': 'blue'}) assert "unknown config key 'colour'" in capsys.readouterr().err diff --git a/suave_cli/test/test_container.py b/suave_cli/test/test_container.py index 339d239..451d760 100644 --- a/suave_cli/test/test_container.py +++ b/suave_cli/test/test_container.py @@ -18,7 +18,7 @@ import pytest -from suave_cli import container, main +from suave_cli import config, container, main from suave_cli.docker_util import RUNNING_FORMAT from suave_cli.errors import CliError @@ -29,8 +29,12 @@ RESULTS_DIR = '/home/ubuntu-user/suave/results' +WS_SRC = '/home/ubuntu-user/suave_ws/src' + + def run_args(**overrides): - values = {'recreate': False, 'src': None, 'yes': False} + values = {'recreate': False, 'src': None, 'yes': False, 'extra_mount': None, + 'no_extra_mounts': False} values.update(overrides) return argparse.Namespace(**values) @@ -158,3 +162,114 @@ def test_docker_missing_is_reported(suave_root, fake_runner, capsys): runner=fake_runner) assert code == 1 assert 'Docker is not available' in capsys.readouterr().err + + +def no_defaults(): + return {'mount_src': False, 'mount_results': False} + + +def test_extra_mounts_follow_defaults(make_ctx, suave_root, tmp_path): + saved, once = tmp_path / 'saved', tmp_path / 'once' + saved.mkdir() + once.mkdir() + file_values = {**results_file(tmp_path), 'extra_mounts': f'{saved}:/data'} + ctx = make_ctx(suave_root, file_values=file_values, + args=run_args(extra_mount=[str(once), f'{once}:/opt/once'])) + assert container.build_mounts(ctx)[2:] == [ + (str(saved), '/data'), (str(once), f'{WS_SRC}/once'), (str(once), '/opt/once')] + + +def test_no_extra_mounts_skips_saved_ones(make_ctx, suave_root, tmp_path): + ctx = make_ctx(suave_root, flags=no_defaults(), file_values={'extra_mounts': '/x:/data'}, + args=run_args(no_extra_mounts=True)) + assert container.build_mounts(ctx) == [] + + +@pytest.mark.parametrize('dest', [SRC_DIR, f'{SRC_DIR}/sub', WS_SRC, RESULTS_DIR]) +def test_overlapping_destinations_are_rejected(make_ctx, suave_root, tmp_path, dest): + ctx = make_ctx(suave_root, file_values={**results_file(tmp_path), + 'extra_mounts': f'{tmp_path}:{dest}'}, + args=run_args()) + with pytest.raises(CliError, match='overlaps'): + container.cmd_run(ctx) + + +def test_nesting_is_allowed_without_the_source_mount(make_ctx, suave_root, tmp_path): + defaults = container.default_mounts(make_ctx(suave_root, flags=no_defaults())) + container.check_extra_mounts(defaults, [(str(tmp_path), f'{SRC_DIR}/sub')]) + + +def test_duplicate_extra_destinations_are_rejected(tmp_path): + with pytest.raises(CliError, match='overlaps'): + container.check_extra_mounts([], [(str(tmp_path), '/data'), (str(tmp_path), '/data')]) + + +def test_missing_mount_source_is_rejected(make_ctx, suave_root, tmp_path): + ctx = make_ctx(suave_root, flags=no_defaults(), + file_values={'extra_mounts': f'{tmp_path}/gone:/data'}, args=run_args()) + with pytest.raises(CliError, match='does not exist'): + container.cmd_run(ctx) + + +def test_matching_extra_mounts_do_not_warn(make_ctx, suave_root, fake_runner, tmp_path, capsys): + fake_runner.responses[STATE] = (0, 'true\n') + fake_runner.responses[MOUNTS] = (0, f'img|{tmp_path}:/data,') + ctx = make_ctx(suave_root, flags=no_defaults(), + file_values={'extra_mounts': f'{tmp_path}:/data'}, args=run_args()) + assert container.cmd_run(ctx) == 0 + assert 'recreate' not in capsys.readouterr().err + + +def cli(suave_root, fake_runner, *argv): + return main.main(list(argv), env={'SUAVE_ROOT': str(suave_root)}, runner=fake_runner) + + +def saved(suave_root): + return config.split_list( + config.load_file(config.config_path(suave_root, False)).get('extra_mounts', '')) + + +def test_mount_add_list_remove(suave_root, fake_runner, tmp_path, capsys): + pkg = tmp_path / 'my_package' + pkg.mkdir() + fake_runner.responses[STATE] = (1, '') + assert cli(suave_root, fake_runner, 'docker', 'mount', 'add', str(pkg)) == 0 + assert cli(suave_root, fake_runner, 'docker', 'mount', 'add', str(pkg), '--to', '/d') == 0 + assert cli(suave_root, fake_runner, 'docker', 'mount', 'add', str(pkg), '--to', '/d') == 0 + assert saved(suave_root) == [f'{pkg}:{WS_SRC}/my_package', f'{pkg}:/d'] + assert 'already mounted' in capsys.readouterr().err + assert cli(suave_root, fake_runner, 'docker', 'mount', 'list') == 0 + assert f'{pkg} -> /d (extra_mounts)' in capsys.readouterr().out + assert cli(suave_root, fake_runner, 'docker', 'mount', 'remove', '/d') == 0 + assert saved(suave_root) == [f'{pkg}:{WS_SRC}/my_package'] + assert cli(suave_root, fake_runner, 'docker', 'mount', 'remove', str(pkg)) == 0 + assert saved(suave_root) == [] + assert cli(suave_root, fake_runner, 'docker', 'mount', 'remove', str(pkg)) == 1 + + +def test_mount_add_rejects_overlap(suave_root, fake_runner, tmp_path, capsys): + code = cli(suave_root, fake_runner, 'docker', 'mount', 'add', str(tmp_path), '--to', SRC_DIR) + assert code == 1 + assert 'overlaps' in capsys.readouterr().err + assert saved(suave_root) == [] + + +def test_mount_add_hints_recreate(suave_root, fake_runner, tmp_path, capsys): + fake_runner.responses[STATE] = (0, 'true\n') + assert cli(suave_root, fake_runner, 'docker', 'mount', 'add', str(tmp_path)) == 0 + assert '--recreate' in capsys.readouterr().err + + +def test_missing_mount_source_does_not_break_other_commands(suave_root, fake_runner, tmp_path): + pkg = tmp_path / 'pkg' + pkg.mkdir() + assert cli(suave_root, fake_runner, 'docker', 'mount', 'add', str(pkg)) == 0 + pkg.rmdir() + assert cli(suave_root, fake_runner, 'config', 'show') == 0 + assert cli(suave_root, fake_runner, 'docker', 'mount', 'list') == 0 + + +def test_mount_remove_fixes_a_bad_entry(suave_root, fake_runner): + config.save_file(config.config_path(suave_root, False), {'extra_mounts': 'broken'}) + assert cli(suave_root, fake_runner, 'docker', 'mount', 'remove', 'broken') == 0 + assert saved(suave_root) == []