Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,7 @@ suave --help # every command, with examples; suave COMMAND
suave --dry-run <command> ... # 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
Expand Down
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,7 @@ suave --help # every command, with examples; suave COMMAND
suave --dry-run <command> ... # 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
Expand Down
1 change: 1 addition & 0 deletions docs/source/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 10 additions & 0 deletions suave_cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
`<container_workspace>/src/<name>` 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 <package>` for new
ROS packages.

## Development

suave self-test # unit tests + linters, no colcon
Expand Down
27 changes: 26 additions & 1 deletion suave_cli/suave_cli/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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'),
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -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

Expand Down
174 changes: 167 additions & 7 deletions suave_cli/suave_cli/container.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -35,20 +35,33 @@
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 = """\
examples:
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 <container_workspace>/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)
Expand All @@ -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']
Expand Down Expand Up @@ -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])
Expand All @@ -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 <package>'.")
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")
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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: <container_workspace>/src/<name>)')
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)
Expand All @@ -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: <container_workspace>/src/<name>)')
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)
1 change: 1 addition & 0 deletions suave_cli/suave_cli/docker_cmd.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
"""

Expand Down
1 change: 1 addition & 0 deletions suave_cli/suave_cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading