Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

refactor: Replace 'screen' use with Docker. WIP #54

Draft
wants to merge 5 commits into
base: main
Choose a base branch
from
Draft
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
38 changes: 29 additions & 9 deletions configs/example.yaml
Original file line number Diff line number Diff line change
@@ -1,12 +1,34 @@
name: Example


launch_args:
log_dir: /mnt/data/roslogs/
rosbag_prefix: /mnt/data/rosbags/phyto-arm
classifier: false
ifcb_winch: false
chanos_winch: false
docker_image: whoi/phyto-arm:latest
log_dir: /data/roslogs/

processes:
main:
enabled: true
launch_args:
rosbag_dir: /data/rosbags/
rosbag_prefix: phyto-arm
classifier: false
tcp_ports:
bridge_node: 9090
web_node: 8098
Comment on lines +13 to +15
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would be nice to find a more elegant way of expressing this. Here, it is attached to the "process" structure, which is pretty far from the actual node configuration some 250 lines later (and called just web).

arm_ifcb:
enabled: true
launch_args:
ifcb_winch: false
routines_dir: /home/ifcb/IFCBacquire/Host/Routines
data_dir: /data/ifcbdata
devices:
ctd_path: /dev/ttyS1
arm_chanos:
enabled: false
launch_args:
chanos_winch: false
devices:
ctd_path: /dev/ttyS2
udp_ports:
rbr_port: 12345


alerts:
Expand All @@ -28,10 +50,8 @@ ifcb:
address: "172.17.0.1"
port: 8092
serial: "111-111-111"
routines_dir: "/routines"
# This path is interpreted by IFCBacquire, which may be in another
# container or namespace.
data_dir: "/mnt/data/ifcbdata"


arm_ifcb:
Expand Down
318 changes: 318 additions & 0 deletions pa
Original file line number Diff line number Diff line change
@@ -0,0 +1,318 @@
#!/usr/bin/env python3
"""
PhytO-ARM Container CLI (pa)

This script manages Docker containers for PhytO-ARM processes, using the original
phyto-arm script within each container to manage ROS nodes.
"""
import argparse
import os
import sys
import time
from pathlib import Path
from typing import Dict, Optional

import docker
import yaml
from docker.models.containers import Container

def create_network(client: docker.DockerClient) -> docker.models.networks.Network:
"""Create or get the Docker network for PhytO-ARM containers"""
try:
return client.networks.get("phyto-arm-net")
except docker.errors.NotFound:
return client.networks.create("phyto-arm-net", driver="bridge")

def get_running_containers(client: docker.DockerClient) -> Dict[str, Container]:
"""Get all running PhytO-ARM containers"""
containers = client.containers.list(filters={"name": "phyto-arm-"})
return {c.name.replace('phyto-arm-', ''): c for c in containers}

# This is here to wire up arguments to volume mounts to ROS launch parameters.
# The alternative is to generate a mount automatically and pass the paths as
# arguments to their respective launch files.
def handle_special_volumes_by_process(process_name: str, process_config: dict) -> dict:
volumes = {}
if process_name == "main":
volumes[process_config['launch_args']['rosbag_dir']] = {
"bind": "/app/volumes/rosbags", # Must match launch file
"mode": "rw"
}
if process_name == "arm_ifcb":
volumes[process_config['launch_args']['routines_dir']] = {
"bind": "/app/volumes/routines", # Must match launch file
"mode": "ro"
}
volumes[process_config['launch_args']['data_dir']] = {
"bind": "/app/volumes/ifcbdata", # Must match launch file
"mode": "rw"
}
return volumes

def start_container(
client: docker.DockerClient,
network: docker.models.networks.Network,
config_path: Path,
process_name: str,
config: dict
) -> Container:
"""Start a single PhytO-ARM container"""

process_config = config['processes'][process_name]
image_name = config['docker_image']
container_name = f"phyto-arm-{process_name}"
log_dir = config['log_dir']
log_mount = "/app/volumes/roslogs"

# Base volumes that all containers need
volumes = {
str(config_path.absolute()): {
"bind": "/app/config.yaml",
"mode": "ro"
},
str(config_path.parent.absolute()): {
"bind": "/app/configs",
"mode": "ro"
},
log_dir: {
"bind": log_mount,
"mode": "rw"
}
}

# Add process-specific volumes
special_volumes = handle_special_volumes_by_process(process_name, process_config)
volumes.update(special_volumes)

# Handle devices
devices = [
f"{path}:{path}"
for path in process_config.get('devices', {}).values()
]

# Handle ports
ports = {}
for port in process_config.get('tcp_ports', {}).values():
ports[f'{port}/tcp'] = port
for port in process_config.get('udp_ports', {}).values():
ports[f'{port}/udp'] = port

# Container configuration
container_config = {
"image": image_name,
"name": container_name,
"command": f"phyto-arm start {process_name} /app/config.yaml",
"detach": True,
"remove": True,
"volumes": volumes,
"network": network.name,
"devices": devices,
"ports": ports,
"environment": {
"DONT_SCREEN": "1", # screen is not needed within containers
"ROS_MASTER_URI": "http://phyto-arm-main:11311",
"ROS_LOG_DIR": log_mount
}
}

if process_name == "main":
container_config["environment"].pop("ROS_MASTER_URI")

try:
container = client.containers.run(**container_config)
print(f"Started container {container_name}")
return container
except docker.errors.APIError as e:
print(f"Failed to start container {container_name}: {e}")
raise

def start_processes(config_path: Path, process_names: Optional[list[str]] = None) -> None:
"""Start PhytO-ARM containers based on config"""
client = docker.DockerClient.from_env()

# Check if any containers are already running
running = get_running_containers(client)
if running and process_names:

# Check for conflicts with requested processes
conflicts = set(process_names) & set(running.keys())
if conflicts:
print("Some requested processes are already running:")
for name in conflicts:
print(f" - {name}")
sys.exit(1)
elif running:
print("Some PhytO-ARM containers are already running:")
for name in running:
print(f" - {name}")
print("Specify process names to start additional containers")
sys.exit(1)

# Load config
with open(config_path) as f:
config = yaml.safe_load(f)

# Create network
network = create_network(client)

# Determine which processes to start
processes = config['processes']
if process_names:
# Validate requested processes exist
invalid = set(process_names) - set(processes.keys())
if invalid:
print(f"Processes not found in config: {', '.join(invalid)}")
sys.exit(1)
# Filter to only requested processes
processes = {k: v for k, v in processes.items() if k in process_names}

# Always start main first if it's in the list
if 'main' in processes:
start_container(
client, network, config_path, 'main', config
)

# Wait for ROS master to start
time.sleep(10)

# Remove main so we don't start it again
processes.pop('main')

# Start remaining processes
for name, process_config in processes.items():
if process_config.get('enabled', True):
start_container(
client, network, config_path, name, config
)

# If process is not enabled in the config, but was requested, print a warning
elif process_names:
print(f"Process {name} is not enabled in the config; skipping")

def stop_processes(process_names: Optional[list[str]] = None) -> None:
"""Stop PhytO-ARM containers"""
client = docker.DockerClient.from_env()
stoppable = get_running_containers(client)

if not stoppable:
print("No PhytO-ARM containers are running")
return

if process_names:

# Validate requested processes exist
invalid = set(process_names) - set(stoppable.keys())
if invalid:
print(f"Processes not running: {', '.join(invalid)}")
sys.exit(1)

# Filter to only requested processes
stoppable = {k: v for k, v in stoppable.items() if k in process_names}

for name, container in stoppable.items():
try:
container.stop()
print(f"Stopped {name}")
except docker.errors.APIError as e:
print(f"Failed to stop {name}: {e}")

def list_processes() -> None:
"""List running PhytO-ARM containers"""
client = docker.DockerClient.from_env()
running = get_running_containers(client)

if not running:
print("No PhytO-ARM containers are running")
return

print("Running processes:")
for name in running:
print(f" - {name}")

def attach_process(process_name: str) -> None:
"""Attach to a running PhytO-ARM container"""
client = docker.DockerClient.from_env()
running = get_running_containers(client)

if not running:
print("No PhytO-ARM containers are running")
return

if process_name not in running:
print(f"Process '{process_name}' is not running")
print("Running processes:", ", ".join(running.keys()))
return

os.system(f"docker attach phyto-arm-{process_name}")

def cleanup() -> None:
"""Remove PhytO-ARM network and any stopped containers"""
client = docker.DockerClient.from_env()

# Remove network
try:
network = client.networks.get("phyto-arm-net")
network.remove()
print("Removed PhytO-ARM network")
except docker.errors.NotFound:
pass

# Remove any stopped containers
containers = client.containers.list(
all=True,
filters={"name": "phyto-arm-"}
)
for container in containers:
try:
container.remove()
print(f"Removed container {container.name}")
except docker.errors.APIError:
pass

def main():
parser = argparse.ArgumentParser(description=__doc__)
subparsers = parser.add_subparsers(dest='command', required=True)

# Start command
start_parser = subparsers.add_parser('start')
start_parser.add_argument('config', type=Path)
start_parser.add_argument('processes', nargs='*',
help='Specific processes to start (default: all enabled)')

# Stop command
stop_parser = subparsers.add_parser('stop')
stop_parser.add_argument('processes', nargs='*',
help='Specific processes to stop (default: all)')

# List command
subparsers.add_parser('list')

# Attach command
attach_parser = subparsers.add_parser('attach')
attach_parser.add_argument('process')

# Cleanup command
subparsers.add_parser('cleanup')

args = parser.parse_args()

try:
if args.command == 'start':
start_processes(args.config, args.processes)
elif args.command == 'stop':
stop_processes(args.processes)
elif args.command == 'list':
list_processes()
elif args.command == 'attach':
attach_process(args.process)
elif args.command == 'cleanup':
cleanup()
except docker.errors.APIError as e:
print(f"Docker error: {e}")
sys.exit(1)
except KeyboardInterrupt:
print("\nOperation interrupted")
sys.exit(1)

if __name__ == '__main__':
main()
9 changes: 7 additions & 2 deletions phyto-arm
100755 → 100644
Original file line number Diff line number Diff line change
Expand Up @@ -65,13 +65,18 @@ def prep_roslaunch(config, env, package, launchfile):
]
rl_args.append(f'config_file:={os.path.abspath(args.config)}')

for launch_arg, value in config.get('launch_args', {}).items():
# Get process-specific launch args if they exist
process_config = config.get('processes', {}).get(args.launch_name, {})
launch_args = process_config.get('launch_args', {})

# Add any process-specific launch args
for launch_arg, value in launch_args.items():
rl_args.append(f'{launch_arg}:={value}')

# Allow the config to set a launch prefix (like gdb) for nodes. We have to
# use an environment variable for this because roslaunch will error if an
# argument is unset, while an environment variable is option.
launch_prefix = config.get('launch_args', {}).get('launch_prefix')
launch_prefix = launch_args.get('launch_prefix')
if launch_prefix is not None:
env = dict(env) # copy first
env['LAUNCH_PREFIX'] = launch_prefix
Expand Down
Loading
Loading