diff --git a/rtwcli/rtw_cmds/rtw_cmds/docker/verbs.py b/rtwcli/rtw_cmds/rtw_cmds/docker/verbs.py index 197f4f46..83daf850 100644 --- a/rtwcli/rtw_cmds/rtw_cmds/docker/verbs.py +++ b/rtwcli/rtw_cmds/rtw_cmds/docker/verbs.py @@ -15,6 +15,7 @@ from rtwcli.docker_utils import ( docker_exec_interactive_bash, docker_start, + fix_missing_xauth_file, is_docker_container_running, ) from rtwcli.verb import VerbExtension @@ -38,6 +39,10 @@ def main(self, *, args): print( f"The docker container '{ws.docker_container_name}' is not running, starting it now." ) + # fix missing .xauth file if it is not present + if not fix_missing_xauth_file(ws.docker_container_name): + print(f"Failed to fix missing .xauth file for '{ws.docker_container_name}'.") + return if not docker_start(ws.docker_container_name): print(f"Failed to start docker container '{ws.docker_container_name}'.") diff --git a/rtwcli/rtw_cmds/rtw_cmds/workspace/create_verb.py b/rtwcli/rtw_cmds/rtw_cmds/workspace/create_verb.py index 760694bb..d5582807 100644 --- a/rtwcli/rtw_cmds/rtw_cmds/workspace/create_verb.py +++ b/rtwcli/rtw_cmds/rtw_cmds/workspace/create_verb.py @@ -14,17 +14,17 @@ import argparse -from dataclasses import dataclass, fields +from dataclasses import dataclass, field, fields import os import pathlib from pprint import pprint import shutil -import subprocess import textwrap from typing import Any, List import questionary from rtwcli.constants import ( BASHRC_PATH, + DISPLAY_MANAGER_WAYLAND, ROS_TEAM_WS_GIT_HTTPS_URL, SKEL_BASHRC_PATH, WORKSPACES_PATH, @@ -37,12 +37,15 @@ docker_stop, is_docker_tag_valid, ) +from rtwcli.rocker_utils import execute_rocker_cmd, generate_rocker_flags from rtwcli.utils import ( create_file_and_write, create_temp_file, + get_display_manager, + get_filtered_args, git_clone, + replace_user_name_in_path, run_bash_command, - run_command, vcs_import, ) from rtwcli.verb import VerbExtension @@ -60,6 +63,7 @@ DEFAULT_BASE_IMAGE_NAME_FORMAT = "osrf/ros:{ros_distro}-desktop" DEFAULT_FINAL_IMAGE_NAME_FORMAT = "rtw_{workspace_name}_final" DEFAULT_CONTAINER_NAME_FORMAT = "{final_image_name}-instance" +DEFAULT_HOSTNAME_FORMAT = "rtw-{workspace_name}-docker" DEFAULT_RTW_DOCKER_BRANCH = "rtw_ws_create" DEFAULT_RTW_DOCKER_PATH = os.path.expanduser("~/ros_team_workspace") DEFAULT_UPSTREAM_WS_NAME_FORMAT = "{workspace_name}_upstream" @@ -86,55 +90,89 @@ DEFAULT_SSH_ABS_PATH = os.path.expanduser("~/.ssh") DEFAULT_INTERMEDIATE_DOCKERFILE_NAME = "Dockerfile" DEFAULT_INTERMEDIATE_DOCKERFILE_SAVE_FOLDER_FORMAT = "{ws_folder}/docker" -DISPLAY_MANAGER_WAYLAND = "wayland" +WS_SRC_FOLDER = "src" @dataclass class CreateVerbArgs: ws_abs_path: str ros_distro: str - docker: bool = False - repos_containing_repository_url: str = None - repos_branch: str = None - repos_no_skip_existing: bool = False - disable_nvidia: bool = False - ws_repos_file_name: str = None - upstream_ws_repos_file_name: str = None - upstream_ws_name: str = None - base_image_name: str = None - final_image_name: str = None - container_name: str = None - rtw_docker_repo_url: str = None - rtw_docker_branch: str = None - rtw_docker_clone_abs_path: str = None - apt_packages: List[str] = None - python_packages: List[str] = None - ssh_abs_path: str = None - intermediate_dockerfile_name: str = None - intermediate_dockerfile_save_folder: str = None + repos_containing_repository_url: str + repos_branch: str + ws_repos_file_name: str + upstream_ws_repos_file_name: str + upstream_ws_name: str + base_image_name: str + final_image_name: str + container_name: str + rtw_docker_repo_url: str + rtw_docker_branch: str + rtw_docker_clone_abs_path: str + ssh_abs_path: str + intermediate_dockerfile_name: str + intermediate_dockerfile_save_folder: str + hostname: str + user_override_name: str has_upstream_ws: bool = False ignore_ws_cmd_error: bool = False + apt_packages: List[str] = field(default_factory=list) + python_packages: List[str] = field(default_factory=list) + standalone: bool = False + repos_no_skip_existing: bool = False + disable_nvidia: bool = False + docker: bool = False @property def ws_name(self) -> str: return pathlib.Path(self.ws_abs_path).name + @property + def ssh_abs_path_in_docker(self) -> str: + if self.user_override_name: + return replace_user_name_in_path(self.ssh_abs_path or "", self.user_override_name) + return self.ssh_abs_path + @property def ws_src_abs_path(self) -> str: - return os.path.join(self.ws_abs_path, "src") + return os.path.join(self.ws_abs_path, WS_SRC_FOLDER) + + @property + def ws_src_abs_path_in_docker(self) -> str: + return os.path.join(self.ws_abs_path_in_docker, WS_SRC_FOLDER) + + @property + def ws_abs_path_in_docker(self) -> str: + if self.user_override_name: + return replace_user_name_in_path(self.ws_abs_path, self.user_override_name) + return self.ws_abs_path @property def upstream_ws_abs_path(self) -> str: return os.path.normpath(os.path.join(self.ws_abs_path, "..", self.upstream_ws_name)) + @property + def upstream_ws_abs_path_in_docker(self) -> str: + if self.user_override_name: + return replace_user_name_in_path(self.upstream_ws_abs_path, self.user_override_name) + return self.upstream_ws_abs_path + @property def upstream_ws_src_abs_path(self) -> str: - return os.path.join(self.upstream_ws_abs_path, "src") + return os.path.join(self.upstream_ws_abs_path, WS_SRC_FOLDER) + + @property + def upstream_ws_src_abs_path_in_docker(self) -> str: + return os.path.join(self.upstream_ws_abs_path_in_docker, WS_SRC_FOLDER) + + @property + def rocker_base_image_name(self) -> str: + # currently no caching, so single image is used + return self.final_image_name @property def repos_containing_repository_name(self) -> str: if not self.repos_containing_repository_url: - return None + return "" return self.repos_containing_repository_url.split("/")[-1].split(".")[0] @property @@ -155,17 +193,6 @@ def intermediate_dockerfile_abs_path(self) -> str: self.intermediate_dockerfile_save_folder, self.intermediate_dockerfile_name ) - @property - def display_manager(self) -> str: - # Command to get the display manager type - cmd = "loginctl show-session $(awk '/tty/ {print $1}' <(loginctl)) -p Type | awk -F= '{print $2}'" - result = subprocess.run(["bash", "-c", cmd], capture_output=True, text=True) - - # Capture the output and remove any trailing newlines or spaces - display_manager = result.stdout.strip() - - return display_manager - def handle_main_ws_repos(self): if not vcs_import( self.ws_repos_file_abs_path, @@ -272,6 +299,8 @@ def set_default_values(self): self.upstream_ws_name = DEFAULT_UPSTREAM_WS_NAME_FORMAT.format( workspace_name=self.ws_name ) + if not self.hostname: + self.hostname = DEFAULT_HOSTNAME_FORMAT.format(workspace_name=self.ws_name) # docker related attributes if not self.base_image_name: @@ -307,6 +336,11 @@ def __post_init__(self): else: print("No repos containing repository URL provided. Not importing any repos.") + if self.user_override_name: + self.rtw_docker_clone_abs_path = replace_user_name_in_path( + self.rtw_docker_clone_abs_path, self.user_override_name + ) + class CreateVerb(VerbExtension): """Create a new ROS workspace.""" @@ -404,6 +438,12 @@ def add_arguments(self, parser: argparse.ArgumentParser, cli_name: str): ), default=None, ) + parser.add_argument( + "--standalone", + action="store_true", + help="Make the Docker image standalone by copying workspace data into the image.", + default=False, + ) parser.add_argument( "--rtw-docker-repo-url", type=str, @@ -462,6 +502,22 @@ def add_arguments(self, parser: argparse.ArgumentParser, cli_name: str): help="Ignore errors when executing workspace commands (rosdep install, colcon build).", default=False, ) + parser.add_argument( + "--hostname", + type=str, + help=( + "Hostname to use for the docker workspace. If not provided, " + "default format is used: " + f"{DEFAULT_HOSTNAME_FORMAT}" + ), + default=None, + ) + parser.add_argument( + "--user-override-name", + type=str, + help="Override the user name for the workspace.", + default=None, + ) def generate_intermediate_dockerfile_content(self, create_args: CreateVerbArgs) -> str: if create_args.apt_packages: @@ -516,6 +572,31 @@ def generate_intermediate_dockerfile_content(self, create_args: CreateVerbArgs) ] ) + # Copy the workspace data into the Docker image if standalone is enabled + if create_args.standalone: + ws_source_path = create_args.ws_name + copy_workspace_cmd = " ".join( + [ + "ADD", + ws_source_path, + create_args.ws_abs_path_in_docker, + ] + ) + if create_args.has_upstream_ws: + upstream_ws_source_path = create_args.upstream_ws_name + copy_upstream_workspace_cmd = " ".join( + [ + "ADD", + upstream_ws_source_path, + create_args.upstream_ws_abs_path_in_docker, + ] + ) + else: + copy_upstream_workspace_cmd = "# no upstream workspace to copy" + else: + copy_workspace_cmd = "# workspace will be mounted as volume" + copy_upstream_workspace_cmd = "# upstream workspace will be mounted as volume" + return textwrap.dedent( f""" FROM {create_args.base_image_name} @@ -524,6 +605,8 @@ def generate_intermediate_dockerfile_content(self, create_args: CreateVerbArgs) {python_packages_cmd} {rtw_clone_cmd} {rtw_install_cmd} + {copy_workspace_cmd} + {copy_upstream_workspace_cmd} RUN rm -rf /var/lib/apt/lists/* """ ) @@ -541,9 +624,9 @@ def build_intermediate_docker_image(self, create_args: CreateVerbArgs): # build intermediate docker image if not docker_build( - create_args.final_image_name, - create_args.intermediate_dockerfile_save_folder, - create_args.intermediate_dockerfile_abs_path, + tag=create_args.final_image_name, + dockerfile_path=os.path.join(create_args.intermediate_dockerfile_save_folder, "../.."), + file=create_args.intermediate_dockerfile_abs_path, ): raise RuntimeError( "Failed to build intermediate docker image for " @@ -557,11 +640,14 @@ def build_intermediate_docker_image(self, create_args: CreateVerbArgs): ) def run_intermediate_container(self, create_args: CreateVerbArgs) -> Any: - volumes = [f"{create_args.ws_abs_path}:{create_args.ws_abs_path}"] - if create_args.has_upstream_ws: - volumes.append( - f"{create_args.upstream_ws_abs_path}:{create_args.upstream_ws_abs_path}" - ) + if create_args.standalone: + volumes = [] # No volumes needed for standalone + else: + volumes = [f"{create_args.ws_abs_path}:{create_args.ws_abs_path}"] + if create_args.has_upstream_ws: + volumes.append( + f"{create_args.upstream_ws_abs_path}:{create_args.upstream_ws_abs_path}" + ) print(f"Creating intermediate docker container with volumes: {volumes}") try: @@ -575,9 +661,9 @@ def run_intermediate_container(self, create_args: CreateVerbArgs) -> Any: volumes=volumes, ) except ( - docker.errors.ContainerError, - docker.errors.ImageNotFound, - docker.errors.APIError, + docker.errors.ContainerError, # type: ignore + docker.errors.ImageNotFound, # type: ignore + docker.errors.APIError, # type: ignore ) as e: raise RuntimeError(f"Failed to create intermediate docker container: {e}") @@ -600,30 +686,47 @@ def get_ws_cmds(self, create_args: CreateVerbArgs) -> List[List[str]]: "--from-paths", ] if has_upstream_ws_packages: - rosdep_cmds.append(rosdep_install_cmd_base + [create_args.upstream_ws_src_abs_path]) + if create_args.docker: + upstream_ws_src_abs_path = create_args.upstream_ws_src_abs_path_in_docker + else: + upstream_ws_src_abs_path = create_args.upstream_ws_src_abs_path + rosdep_cmds.append(rosdep_install_cmd_base + [upstream_ws_src_abs_path]) if has_ws_packages: - rosdep_cmds.append(rosdep_install_cmd_base + [create_args.ws_src_abs_path]) + if create_args.docker: + ws_src_abs_path = create_args.ws_src_abs_path_in_docker + else: + ws_src_abs_path = create_args.ws_src_abs_path + rosdep_cmds.append(rosdep_install_cmd_base + [ws_src_abs_path]) rosdep_cmds.append(["sudo", "rm", "-rf", "/var/lib/apt/lists/*"]) compile_cmds = [] + if create_args.docker: + ws_abs_path = create_args.ws_abs_path_in_docker + else: + ws_abs_path = create_args.ws_abs_path if create_args.has_upstream_ws: - compile_cmds.append( - get_compile_cmd(create_args.upstream_ws_abs_path, create_args.ros_distro) - ) + if create_args.docker: + upstream_ws_abs_path = create_args.upstream_ws_abs_path_in_docker + else: + upstream_ws_abs_path = create_args.upstream_ws_abs_path + compile_cmds.append(get_compile_cmd(upstream_ws_abs_path, create_args.ros_distro)) compile_cmds.append( get_compile_cmd( - create_args.ws_abs_path, + ws_abs_path, create_args.ros_distro, - upstream_ws_abs_path=create_args.upstream_ws_abs_path, + upstream_ws_abs_path=upstream_ws_abs_path, ) ) else: - compile_cmds.append(get_compile_cmd(create_args.ws_abs_path, create_args.ros_distro)) + compile_cmds.append(get_compile_cmd(ws_abs_path, create_args.ros_distro)) return rosdep_cmds + compile_cmds def execute_ws_cmds( - self, create_args: CreateVerbArgs, ws_cmds: List[List[str]], intermediate_container=None - ): + self, + create_args: CreateVerbArgs, + ws_cmds: List[List[str]], + intermediate_container: Any = None, + ) -> None: for ws_cmd in ws_cmds: print(f"Running ws command: {ws_cmd}") ws_cmd_str = " ".join(ws_cmd) @@ -646,21 +749,23 @@ def change_ws_folder_permissions( self, create_args: CreateVerbArgs, intermediate_container: Any ): print("Changing workspace folder permissions in the intermediate container.") - if not change_docker_path_permissions(intermediate_container.id, create_args.ws_abs_path): + if not change_docker_path_permissions( + intermediate_container.id, create_args.ws_abs_path_in_docker + ): docker_stop(intermediate_container.id) raise RuntimeError( "Failed to change permissions for the main workspace folder " - f"{create_args.ws_abs_path}" + f"{create_args.ws_abs_path_in_docker}" ) if create_args.has_upstream_ws: if not change_docker_path_permissions( - intermediate_container.id, create_args.upstream_ws_abs_path + intermediate_container.id, create_args.upstream_ws_abs_path_in_docker ): docker_stop(intermediate_container.id) raise RuntimeError( "Failed to change permissions for the upstream workspace folder " - f"{create_args.upstream_ws_abs_path}" + f"{create_args.upstream_ws_abs_path_in_docker}" ) def setup_rtw_in_intermediate_image( @@ -670,18 +775,18 @@ def setup_rtw_in_intermediate_image( docker_workspaces_config = WorkspacesConfig() if create_args.has_upstream_ws: docker_workspaces_config.add_workspace( - create_args.upstream_ws_name, Workspace( + ws_name=create_args.upstream_ws_name, distro=create_args.ros_distro, - ws_folder=create_args.upstream_ws_abs_path, + ws_folder=create_args.upstream_ws_abs_path_in_docker, ), ) docker_workspaces_config.add_workspace( - create_args.ws_name, Workspace( + ws_name=create_args.ws_name, distro=create_args.ros_distro, - ws_folder=create_args.ws_abs_path, - base_ws=create_args.upstream_ws_name if create_args.has_upstream_ws else None, + ws_folder=create_args.ws_abs_path_in_docker, + base_ws=create_args.upstream_ws_name if create_args.has_upstream_ws else "", ), ) @@ -690,7 +795,15 @@ def setup_rtw_in_intermediate_image( docker_stop(intermediate_container.id) raise RuntimeError("Failed to save rtw workspaces file.") - if not docker_cp(intermediate_container.id, temp_rtw_file, WORKSPACES_PATH): + if not docker_cp( + container_name=intermediate_container.id, + src_path=temp_rtw_file, + dest_path=( + replace_user_name_in_path(WORKSPACES_PATH, create_args.user_override_name) + if create_args.user_override_name + else WORKSPACES_PATH + ), + ): docker_stop(intermediate_container.id) raise RuntimeError("Failed to copy rtw workspaces file to container.") @@ -719,99 +832,57 @@ def setup_rtw_in_intermediate_image( # write the default bashrc with the extra content to the container temp_bashrc_file = create_temp_file(content=default_bashrc_content + extra_bashrc_content) - if not docker_cp(intermediate_container.id, temp_bashrc_file, BASHRC_PATH): + if not docker_cp( + intermediate_container.id, + temp_bashrc_file, + ( + replace_user_name_in_path(BASHRC_PATH, create_args.user_override_name) + if create_args.user_override_name + else BASHRC_PATH + ), + ): docker_stop(intermediate_container.id) raise RuntimeError("Failed to copy bashrc file to container.") # change ownership of the whole home folder - if not change_docker_path_permissions(intermediate_container.id, os.path.expanduser("~")): + if not change_docker_path_permissions( + intermediate_container.id, + ( + replace_user_name_in_path(os.path.expanduser("~"), create_args.user_override_name) + if create_args.user_override_name + else os.path.expanduser("~") + ), + ): raise RuntimeError("Failed to change permissions for the home folder.") print(f"Committing container '{intermediate_container.id}'") try: intermediate_container.commit(create_args.final_image_name) - except docker.errors.APIError as e: + except docker.errors.APIError as e: # type: ignore docker_stop(intermediate_container.id) raise RuntimeError(f"Failed to commit container '{intermediate_container.id}': {e}") # stop the intermediate container after committing docker_stop(intermediate_container.id) - def generate_rocker_flags(self, create_args: CreateVerbArgs) -> List[str]: - # rocker flags have order, see rocker --help - rocker_flags = ["--nocache", "--nocleanup", "--git"] - - rocker_flags.extend(["-e", "QT_QPA_PLATFORM=xcb"]) - if not create_args.disable_nvidia: - rocker_flags.extend(["-e", "__GLX_VENDOR_LIBRARY_NAME=nvidia"]) - rocker_flags.extend(["-e", "__NV_PRIME_RENDER_OFFLOAD=1"]) - if create_args.display_manager == DISPLAY_MANAGER_WAYLAND: - waylad_display = os.environ.get("WAYLAND_DISPLAY", None) - if not waylad_display: - raise RuntimeError("WAYLAND_DISPLAY is not set.") - rocker_flags.extend(["-e", "XDG_RUNTIME_DIR=/tmp"]) - rocker_flags.extend(["-e", f"WAYLAND_DISPLAY={waylad_display}"]) - rocker_flags.extend( - ["-v", f"{os.environ['XDG_RUNTIME_DIR']}/{waylad_display}:/tmp/{waylad_display}"] - ) - - rocker_flags.extend(["--hostname", f"rtw-{create_args.ws_name}-docker"]) - rocker_flags.extend(["--name", f"{create_args.container_name}"]) - rocker_flags.extend(["--network", "host"]) - - if not create_args.disable_nvidia: - rocker_flags.extend(["--nvidia", "gpus"]) - - rocker_flags.extend(["--user", "--user-preserve-home"]) - - # rocker volumes - rocker_flags.append("--volume") - rocker_flags.append(f"{create_args.ssh_abs_path}:{create_args.ssh_abs_path}:ro") - rocker_flags.append(f"{create_args.ws_abs_path}:{create_args.ws_abs_path}") - if create_args.has_upstream_ws: - rocker_flags.append( - f"{create_args.upstream_ws_abs_path}:{create_args.upstream_ws_abs_path}" + def main(self, *, args): + ws_name = os.path.basename(args.ws_folder) + if ws_name in get_workspace_names(): + raise RuntimeError( + f"Workspace with name '{ws_name}' already exists. " + "Overwriting existing workspaces is not supported yet." ) - rocker_flags.append("--x11tmp") - rocker_flags.extend(["--mode", "interactive"]) - rocker_flags.extend(["--image-name", f"{create_args.final_image_name}"]) - - return rocker_flags - - def execute_rocker_cmd(self, create_args: CreateVerbArgs) -> bool: - rocker_flags = self.generate_rocker_flags(create_args) - rocker_base_image_name = create_args.final_image_name - rocker_cmd = ["rocker"] + rocker_flags + [rocker_base_image_name] - rocker_cmd_str = " ".join(rocker_cmd) - - print( - f"Creating final image '{create_args.final_image_name}' " - f"and container '{create_args.container_name}' " - f"with command '{rocker_cmd_str}'" - ) - - return run_command(rocker_cmd) + if get_display_manager() == DISPLAY_MANAGER_WAYLAND: + print(f"Wayland display manager detected: '{DISPLAY_MANAGER_WAYLAND}'.") - def main(self, *, args): - args_dict = vars(args) - valid_fields = {field.name for field in fields(CreateVerbArgs)} - filtered_args = {key: args_dict[key] for key in valid_fields if key in args_dict} + filtered_args = get_filtered_args(args, list(fields(CreateVerbArgs))) filtered_args["ws_abs_path"] = os.path.normpath(os.path.abspath(args.ws_folder)) create_args = CreateVerbArgs(**filtered_args) print("### CREATE ARGS ###") pprint(create_args) print("### CREATE ARGS ###") - if create_args.display_manager == DISPLAY_MANAGER_WAYLAND: - print(f"Wayland display manager detected: '{create_args.display_manager}'") - - ws_names = get_workspace_names() - if create_args.ws_name in ws_names: - raise RuntimeError( - f"Workspace with name '{create_args.ws_name}' already exists. " - "Overwriting existing workspaces is not supported yet." - ) if create_args.docker: self.build_intermediate_docker_image(create_args) @@ -829,7 +900,29 @@ def main(self, *, args): if create_args.docker: self.change_ws_folder_permissions(create_args, intermediate_container) self.setup_rtw_in_intermediate_image(create_args, intermediate_container) - if not self.execute_rocker_cmd(create_args): + + rocker_ws_volumes = [] + if not create_args.standalone: + rocker_ws_volumes.append( + f"{create_args.ws_abs_path}:{create_args.ws_abs_path_in_docker}" + ) + if create_args.has_upstream_ws: + rocker_ws_volumes.append( + f"{create_args.upstream_ws_abs_path}:{create_args.upstream_ws_abs_path_in_docker}" + ) + + rocker_flags = generate_rocker_flags( + disable_nvidia=create_args.disable_nvidia, + container_name=create_args.container_name, + hostname=create_args.hostname, + ssh_abs_path=create_args.ssh_abs_path, + ssh_abs_path_in_docker=create_args.ssh_abs_path_in_docker, + final_image_name=create_args.final_image_name, + ws_volumes=rocker_ws_volumes, + user_override_name=create_args.user_override_name, + ) + + if not execute_rocker_cmd(rocker_flags, create_args.rocker_base_image_name): # ask the user to still save ws config even if there was a rocker error still_save_config = questionary.confirm( "Rocker command failed. Do you still want to save the workspace config?" @@ -840,25 +933,33 @@ def main(self, *, args): # create local upstream workspace if create_args.has_upstream_ws: local_upstream_ws = Workspace( + ws_name=create_args.upstream_ws_name, ws_folder=create_args.upstream_ws_abs_path, distro=create_args.ros_distro, ws_docker_support=True if create_args.docker else False, - docker_tag=create_args.final_image_name if create_args.docker else None, - docker_container_name=create_args.container_name if create_args.docker else None, + docker_tag=create_args.final_image_name if create_args.docker else "", + docker_container_name=create_args.container_name if create_args.docker else "", + standalone=create_args.standalone, ) - if not update_workspaces_config( - WORKSPACES_PATH, create_args.upstream_ws_name, local_upstream_ws - ): + if not update_workspaces_config(WORKSPACES_PATH, local_upstream_ws): raise RuntimeError("Failed to update workspaces config with upstream workspace.") # create local main workspace local_main_ws = Workspace( + ws_name=create_args.ws_name, ws_folder=create_args.ws_abs_path, distro=create_args.ros_distro, ws_docker_support=True if create_args.docker else False, - docker_tag=create_args.final_image_name if create_args.docker else None, - base_ws=create_args.upstream_ws_name if create_args.has_upstream_ws else None, - docker_container_name=create_args.container_name if create_args.docker else None, + docker_tag=create_args.final_image_name if create_args.docker else "", + base_ws=create_args.upstream_ws_name if create_args.has_upstream_ws else "", + docker_container_name=create_args.container_name if create_args.docker else "", + standalone=create_args.standalone, ) - if not update_workspaces_config(WORKSPACES_PATH, create_args.ws_name, local_main_ws): + if not update_workspaces_config(WORKSPACES_PATH, local_main_ws): raise RuntimeError("Failed to update workspaces config with main workspace.") + + # remove the local files if the standalone flag is set + if create_args.standalone: + shutil.rmtree(create_args.ws_abs_path) + if create_args.has_upstream_ws: + shutil.rmtree(create_args.upstream_ws_abs_path) diff --git a/rtwcli/rtw_cmds/rtw_cmds/workspace/import_verb.py b/rtwcli/rtw_cmds/rtw_cmds/workspace/import_verb.py new file mode 100644 index 00000000..7f2bf4db --- /dev/null +++ b/rtwcli/rtw_cmds/rtw_cmds/workspace/import_verb.py @@ -0,0 +1,164 @@ +# Copyright (c) 2023, Stogl Robotics Consulting UG (haftungsbeschränkt) +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import argparse +from dataclasses import dataclass, fields + +import questionary +from rtw_cmds.workspace.create_verb import ( + DEFAULT_HOSTNAME_FORMAT, + DEFAULT_SSH_ABS_PATH, + DEFAULT_FINAL_IMAGE_NAME_FORMAT, + DEFAULT_CONTAINER_NAME_FORMAT, +) +from rtwcli.constants import WORKSPACES_PATH +from rtwcli.rocker_utils import execute_rocker_cmd, generate_rocker_flags +from rtwcli.utils import get_filtered_args, replace_user_name_in_path +from rtwcli.verb import VerbExtension +from rtwcli.workspace_manger import Workspace, update_workspaces_config + + +@dataclass +class ImportVerbArgs: + ws_name: str + ros_distro: str + standalone_docker_image: str + docker: bool = True + disable_nvidia: bool = False + standalone: bool = True + final_image_name: str = "" + container_name: str = "" + ssh_abs_path: str = "" + hostname: str = "" + user_override_name: str = "" + ws_abs_path: str = "" + + @property + def ssh_abs_path_in_docker(self) -> str: + if self.user_override_name: + return replace_user_name_in_path(self.ssh_abs_path, self.user_override_name) + return self.ssh_abs_path + + def __post_init__(self): + if not self.hostname: + self.hostname = DEFAULT_HOSTNAME_FORMAT.format(workspace_name=self.ws_name) + if not self.final_image_name: + self.final_image_name = DEFAULT_FINAL_IMAGE_NAME_FORMAT.format( + workspace_name=self.ws_name + ) + if not self.container_name: + self.container_name = DEFAULT_CONTAINER_NAME_FORMAT.format( + final_image_name=self.final_image_name + ) + + +class ImportVerb(VerbExtension): + """Import workspace by creating the corresponding config entry.""" + + def add_arguments(self, parser: argparse.ArgumentParser, cli_name: str): + parser.formatter_class = argparse.ArgumentDefaultsHelpFormatter + parser.add_argument("--ws-name", type=str, help="Name of the workspace.", required=True) + parser.add_argument( + "--ros-distro", + type=str, + help="ROS distro of the workspace.", + required=True, + choices=["humble", "rolling"], + ) + parser.add_argument( + "--standalone-docker-image", + type=str, + required=False, + help="Standalone docker image to use for the workspace.", + ) + parser.add_argument( + "--disable-nvidia", + action="store_true", + help="Disable nvidia rocker flag", + default=False, + ) + parser.add_argument( + "--final-image-name", + type=str, + help=( + "Final image name to use for the docker workspace. If not provided, " + f"default format is used: {DEFAULT_FINAL_IMAGE_NAME_FORMAT}" + ), + default=None, + ) + parser.add_argument( + "--container-name", + type=str, + help=( + "Name of the docker container to use. If not provided, " + f"default format is used: {DEFAULT_CONTAINER_NAME_FORMAT}" + ), + default=None, + ) + parser.add_argument( + "--ssh-abs-path", + type=str, + help="Absolute path to the ssh folder.", + default=DEFAULT_SSH_ABS_PATH, + ) + parser.add_argument( + "--hostname", + type=str, + help=( + "Hostname to use for the docker workspace. If not provided, " + "default format is used: " + f"{DEFAULT_HOSTNAME_FORMAT}" + ), + default=None, + ) + parser.add_argument( + "--user-override-name", + type=str, + help="Override the user name for the workspace.", + default=None, + ) + + def main(self, *, args): + filtered_args = get_filtered_args(args, list(fields(ImportVerbArgs))) + import_args = ImportVerbArgs(**filtered_args) + rocker_flags = generate_rocker_flags( + disable_nvidia=import_args.disable_nvidia, + container_name=import_args.container_name, + hostname=import_args.hostname, + ssh_abs_path=import_args.ssh_abs_path, + ssh_abs_path_in_docker=import_args.ssh_abs_path_in_docker, + final_image_name=import_args.final_image_name, + user_override_name=import_args.user_override_name, + ) + + if not execute_rocker_cmd(rocker_flags, import_args.standalone_docker_image): + # ask the user to still save ws config even if there was a rocker error + still_save_config = questionary.confirm( + "Rocker command failed. Do you still want to save the workspace config?" + ).ask() + if not still_save_config: + exit("Not saving the workspace config.") + + # create local main workspace + local_main_ws = Workspace( + ws_name=import_args.ws_name, + ws_folder=import_args.ws_abs_path, + distro=import_args.ros_distro, + ws_docker_support=import_args.docker, + docker_tag=import_args.final_image_name, + docker_container_name=import_args.container_name, + standalone=import_args.standalone, + ) + if not update_workspaces_config(WORKSPACES_PATH, local_main_ws): + raise RuntimeError("Failed to update workspaces config with main workspace.") diff --git a/rtwcli/rtw_cmds/rtw_cmds/workspace/port_verb.py b/rtwcli/rtw_cmds/rtw_cmds/workspace/port_verb.py index 591693b8..fb51e9db 100644 --- a/rtwcli/rtw_cmds/rtw_cmds/workspace/port_verb.py +++ b/rtwcli/rtw_cmds/rtw_cmds/workspace/port_verb.py @@ -18,7 +18,7 @@ from rtwcli.constants import ( F_DISTRO, F_WS_FOLDER, - WS_FOLDER_ENV_VAR, + ROS_TEAM_WS_WS_FOLDER_ENV_VAR, ROS_TEAM_WS_RC_PATH, ROS_TEAM_WS_ENV_VARIABLES, ) @@ -85,7 +85,7 @@ def port_all_workspaces( workspace_data_to_port[ws_var] = ws_var_value print("Generating workspace name from workspace path with first folder letters: ") - ws_path = script_ws_data[WS_FOLDER_ENV_VAR] + ws_path = script_ws_data[ROS_TEAM_WS_WS_FOLDER_ENV_VAR] new_ws_name = os.path.basename(ws_path) print(f"\t'{ws_path}' -> {new_ws_name}") @@ -123,7 +123,12 @@ def port_current_workspace(self, var_str_format: str = "\t{:>30} -> {:<20}: {}") workspace_data_to_port[ws_var] = ws_var_value print("Generating workspace name from workspace path with first folder letters: ") - ws_path = os.environ.get(WS_FOLDER_ENV_VAR) + ws_path = os.environ.get(ROS_TEAM_WS_WS_FOLDER_ENV_VAR) + if not ws_path: + raise RuntimeError( + f"Environment variable '{ROS_TEAM_WS_WS_FOLDER_ENV_VAR}' is not set." + ) + new_ws_name = os.path.basename(ws_path) print(f"\t'{ws_path}' -> {new_ws_name}") diff --git a/rtwcli/rtw_cmds/rtw_cmds/workspace/use_verb.py b/rtwcli/rtw_cmds/rtw_cmds/workspace/use_verb.py index fe7bbbd9..b657b1ed 100644 --- a/rtwcli/rtw_cmds/rtw_cmds/workspace/use_verb.py +++ b/rtwcli/rtw_cmds/rtw_cmds/workspace/use_verb.py @@ -13,10 +13,13 @@ # limitations under the License. import argparse -import copy import os import questionary -from rtwcli.constants import USE_WORKSPACE_SCRIPT_PATH, WORKSPACES_KEY, WORKSPACES_PATH +from rtwcli.constants import ( + USE_WORKSPACE_SCRIPT_PATH, + WORKSPACES_PATH, + WS_USE_BASH_FILE_PATH_FORMAT, +) from rtwcli.utils import create_file_and_write from rtwcli.verb import VerbExtension from rtwcli.workspace_manger import ( @@ -32,7 +35,7 @@ def add_rtw_workspace_use_args(parser: argparse.ArgumentParser): help="The workspace name", nargs="?", ) - arg.completer = workspace_name_completer + arg.completer = workspace_name_completer # type: ignore class UseVerb(VerbExtension): @@ -55,7 +58,7 @@ def main(self, *, args): "Choose workspace", ws_names, qmark="'Tab' to see all workspaces, type to filter, 'Enter' to select\n", - meta_information=copy.deepcopy(workspaces_config.to_dict()[WORKSPACES_KEY]), + meta_information=workspaces_config.ws_meta_information, validate=lambda ws_choice: ws_choice in ws_names, style=questionary.Style([("answer", "bg:ansiwhite")]), match_middle=True, @@ -72,7 +75,7 @@ def main(self, *, args): script_content = create_bash_script_content_for_using_ws( workspace, USE_WORKSPACE_SCRIPT_PATH ) - tmp_file = f"/tmp/ros_team_workspace/wokspace_{os.getppid()}.bash" + tmp_file = WS_USE_BASH_FILE_PATH_FORMAT.format(ppid=os.getppid()) print(f"Following text will be written into file '{tmp_file}':\n{script_content}") if not create_file_and_write(tmp_file, content=script_content): return f"Failed to write workspace data to a file {tmp_file}." diff --git a/rtwcli/rtw_cmds/setup.py b/rtwcli/rtw_cmds/setup.py index aeb1d7a3..3ba9678e 100644 --- a/rtwcli/rtw_cmds/setup.py +++ b/rtwcli/rtw_cmds/setup.py @@ -55,6 +55,7 @@ ], "rtw_cmds.workspace.verbs": [ "create = rtw_cmds.workspace.create_verb:CreateVerb", + "import = rtw_cmds.workspace.import_verb:ImportVerb", "port = rtw_cmds.workspace.port_verb:PortVerb", "use = rtw_cmds.workspace.use_verb:UseVerb", ], diff --git a/rtwcli/rtwcli/rtwcli/constants.py b/rtwcli/rtwcli/rtwcli/constants.py index b234b7b1..4fe7bd57 100644 --- a/rtwcli/rtwcli/rtwcli/constants.py +++ b/rtwcli/rtwcli/rtwcli/constants.py @@ -27,8 +27,11 @@ ROS_TEAM_WS_RC_PATH = os.path.expanduser("~/.ros_team_ws_rc") BACKUP_DATETIME_FORMAT = "%Y-%m-%d_%H-%M-%S-%f" WORKSPACES_PATH_BACKUP_FORMAT = os.path.join(ROS_TEAM_WS_PATH, "bkp", "workspaces_bkp_{}.yaml") -WS_FOLDER_ENV_VAR = "RosTeamWS_WS_FOLDER" -ROS_TEAM_WS_PREFIX = "RosTeamWS_" + +SKEL_BASHRC_PATH = "/etc/skel/.bashrc" +BASHRC_PATH = os.path.expanduser("~/.bashrc") + +WS_USE_BASH_FILE_PATH_FORMAT = "/tmp/ros_team_workspace/workspace_{ppid}.bash" # constants for workspace field names F_BASE_WS = "base_ws" @@ -37,14 +40,23 @@ F_WS_DOCKER_SUPPORT = "ws_docker_support" F_WS_FOLDER = "ws_folder" F_DOCKER_CONTAINER_NAME = "docker_container_name" +F_WS_NAME = "ws_name" -SKEL_BASHRC_PATH = "/etc/skel/.bashrc" -BASHRC_PATH = os.path.expanduser("~/.bashrc") +ROS_TEAM_WS_PREFIX = "RosTeamWS_" +ROS_TEAM_WS_BASE_WS_ENV_VAR = ROS_TEAM_WS_PREFIX + F_BASE_WS.upper() +ROS_TEAM_WS_DISTRO_ENV_VAR = ROS_TEAM_WS_PREFIX + F_DISTRO.upper() +ROS_TEAM_WS_DOCKER_TAG_ENV_VAR = ROS_TEAM_WS_PREFIX + F_DOCKER_TAG.upper() +ROS_TEAM_WS_WS_DOCKER_SUPPORT_ENV_VAR = ROS_TEAM_WS_PREFIX + F_WS_DOCKER_SUPPORT.upper() +ROS_TEAM_WS_WS_FOLDER_ENV_VAR = ROS_TEAM_WS_PREFIX + F_WS_FOLDER.upper() +ROS_TEAM_WS_DOCKER_CONTAINER_NAME_ENV_VAR = ROS_TEAM_WS_PREFIX + F_DOCKER_CONTAINER_NAME.upper() +ROS_TEAM_WS_WS_NAME_ENV_VAR = ROS_TEAM_WS_PREFIX + F_WS_NAME.upper() ROS_TEAM_WS_ENV_VARIABLES = [ - "RosTeamWS_BASE_WS", - "RosTeamWS_DISTRO", - "RosTeamWS_WS_FOLDER", - "RosTeamWS_WS_DOCKER_SUPPORT", - "RosTeamWS_DOCKER_TAG", + ROS_TEAM_WS_BASE_WS_ENV_VAR, + ROS_TEAM_WS_DISTRO_ENV_VAR, + ROS_TEAM_WS_WS_FOLDER_ENV_VAR, + ROS_TEAM_WS_WS_DOCKER_SUPPORT_ENV_VAR, + ROS_TEAM_WS_DOCKER_TAG_ENV_VAR, ] + +DISPLAY_MANAGER_WAYLAND = "wayland" diff --git a/rtwcli/rtwcli/rtwcli/docker_utils.py b/rtwcli/rtwcli/rtwcli/docker_utils.py index 64ac5fb3..68270d00 100644 --- a/rtwcli/rtwcli/rtwcli/docker_utils.py +++ b/rtwcli/rtwcli/rtwcli/docker_utils.py @@ -13,8 +13,9 @@ # limitations under the License. import os +from typing import Union -from rtwcli.utils import run_command +from rtwcli.utils import create_file_if_not_exists, run_command import docker @@ -23,13 +24,20 @@ def is_docker_tag_valid(tag: str) -> bool: try: docker_client = docker.from_env() return docker_client.images.get(tag) is not None - except (docker.errors.ImageNotFound, docker.errors.APIError) as e: + except ( + docker.errors.ImageNotFound, # type: ignore + docker.errors.APIError, # type: ignore + ) as e: print(f"Failed to get docker image '{tag}': {e}") return False def docker_build( - tag: str, dockerfile_path: str, file: str = None, pull: bool = True, no_cache: bool = True + tag: str, + dockerfile_path: str, + file: Union[str, None] = None, + pull: bool = True, + no_cache: bool = True, ) -> bool: """Build a docker image with the given tag from the given dockerfile path.""" docker_build_command = ["docker", "build", "-t", tag] @@ -112,16 +120,97 @@ def is_docker_container_running(id_or_name: str, running_status: str = "running" docker_client = docker.from_env() container = docker_client.containers.get(id_or_name) return container.status == running_status - except (docker.errors.NotFound, docker.errors.APIError) as e: + except ( + docker.errors.NotFound, # type: ignore + docker.errors.APIError, # type: ignore + ) as e: print(f"Failed to get docker container '{id_or_name}': {e}") return False def change_docker_path_permissions( - container_name: str, path: str, user_in: str = None, group_in: str = None + container_name: str, + path: str, + user_in: Union[str, None] = None, + group_in: Union[str, None] = None, ) -> bool: """Change the permissions of the given path in the given container to the given user and group.""" user = user_in if user_in else os.getuid() group = group_in if group_in else os.getgid() print(f"Changing permissions of the path '{path}' to '{user}:{group}' in '{container_name}'.") return docker_exec_bash_cmd(container_name, f"chown -R {user}:{group} {path}") + + +def fix_missing_xauth_file( + container_name: str, + mounts_attr: str = "Mounts", + source_key: str = "Source", + xauth_file_ext: str = ".xauth", +) -> bool: + """Fix missing xauth file for the given container.""" + try: + docker_client = docker.from_env() + container = docker_client.containers.get(container_name) + except ( + docker.errors.NotFound, # type: ignore + docker.errors.APIError, # type: ignore + ) as e: + print(f"Failed to get docker container '{container_name}': {e}") + return False + + if not container: + print(f"Container object is None for container '{container_name}'.") + return False + + if not container.attrs: + print(f"Container attributes are None for container '{container_name}'.") + return False + + if mounts_attr not in container.attrs: + print( + f"Container attributes do not contain '{mounts_attr}' for container '{container_name}'." + ) + return False + + xauth_file_abs_path = None + for mount in container.attrs[mounts_attr]: + if source_key in mount and xauth_file_ext in mount[source_key]: + xauth_file_abs_path = mount[source_key] + print( + f"Found {xauth_file_ext} file '{xauth_file_abs_path}' for container '{container_name}'." + ) + break + + if not xauth_file_abs_path: + print( + f"There is no {xauth_file_ext} file for container '{container_name}'. Nothing to do." + ) + return True + + if os.path.isfile(xauth_file_abs_path): + print(f"File '{xauth_file_abs_path}' already exists.") + return True + + if os.path.isdir(xauth_file_abs_path): + print(f"Path '{xauth_file_abs_path}' is a directory, removing it.") + try: + os.rmdir(xauth_file_abs_path) + except OSError as e: + print(f"Failed to remove directory '{xauth_file_abs_path}': {e}") + print("========================================") + print("Please remove it manually and try again.") + print("========================================") + return False + + if not create_file_if_not_exists(xauth_file_abs_path): + print(f"Failed to create file '{xauth_file_abs_path}'.") + return False + + cmd = f"xauth nlist :0 | sed -e 's/^..../ffff/' | xauth -f {xauth_file_abs_path} nmerge -" + + if not run_command(cmd, shell=True): + print(f"Failed to run command '{cmd}'. File '{xauth_file_abs_path}' will be removed.") + os.remove(xauth_file_abs_path) + return False + + return True diff --git a/rtwcli/rtwcli/rtwcli/rocker_utils.py b/rtwcli/rtwcli/rtwcli/rocker_utils.py new file mode 100644 index 00000000..9130462a --- /dev/null +++ b/rtwcli/rtwcli/rtwcli/rocker_utils.py @@ -0,0 +1,80 @@ +# Copyright (c) 2023, Stogl Robotics Consulting UG (haftungsbeschränkt) +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import os +from typing import List, Union + +from rtwcli.constants import DISPLAY_MANAGER_WAYLAND +from rtwcli.utils import get_display_manager, run_command + + +def generate_rocker_flags( + disable_nvidia: bool, + container_name: str, + hostname: str, + ssh_abs_path: str, + ssh_abs_path_in_docker: str, + final_image_name: str, + ws_volumes: Union[List[str], None] = None, + user_override_name: Union[str, None] = None, +) -> List[str]: + # rocker flags have order, see rocker --help + rocker_flags = ["--nocache", "--nocleanup", "--git"] + + rocker_flags.extend(["-e", "QT_QPA_PLATFORM=xcb"]) + if not disable_nvidia: + rocker_flags.extend(["-e", "__GLX_VENDOR_LIBRARY_NAME=nvidia"]) + rocker_flags.extend(["-e", "__NV_PRIME_RENDER_OFFLOAD=1"]) + if get_display_manager() == DISPLAY_MANAGER_WAYLAND: + waylad_display = os.environ.get("WAYLAND_DISPLAY", None) + if not waylad_display: + raise RuntimeError("WAYLAND_DISPLAY is not set.") + rocker_flags.extend(["-e", "XDG_RUNTIME_DIR=/tmp"]) + rocker_flags.extend(["-e", f"WAYLAND_DISPLAY={waylad_display}"]) + rocker_flags.extend( + [ + "--volume", + f"{os.environ['XDG_RUNTIME_DIR']}/{waylad_display}:/tmp/{waylad_display}", + ] + ) + + rocker_flags.extend(["--hostname", hostname]) + rocker_flags.extend(["--name", container_name]) + rocker_flags.extend(["--network", "host"]) + + if not disable_nvidia: + rocker_flags.extend(["--nvidia", "gpus"]) + + rocker_flags.extend(["--user", "--user-preserve-home"]) + if user_override_name: + rocker_flags.extend(["--user-override-name", user_override_name]) + + # rocker volumes + rocker_flags.append("--volume") + rocker_flags.append(f"{ssh_abs_path}:{ssh_abs_path_in_docker}:ro") + if ws_volumes: + rocker_flags.extend(ws_volumes) + + rocker_flags.append("--x11tmp") + rocker_flags.extend(["--mode", "interactive"]) + rocker_flags.extend(["--image-name", f"{final_image_name}"]) + + return rocker_flags + + +def execute_rocker_cmd(rocker_flags: List[str], rocker_base_image_name: str) -> bool: + rocker_cmd = ["rocker"] + rocker_flags + [rocker_base_image_name] + rocker_cmd_str = " ".join(rocker_cmd) + print(f"Executing rocker command '{rocker_cmd_str}'") + return run_command(rocker_cmd) diff --git a/rtwcli/rtwcli/rtwcli/utils.py b/rtwcli/rtwcli/rtwcli/utils.py index a8afc9f6..211ce24c 100644 --- a/rtwcli/rtwcli/rtwcli/utils.py +++ b/rtwcli/rtwcli/rtwcli/utils.py @@ -13,10 +13,13 @@ # limitations under the License. +import argparse +from dataclasses import Field +import getpass import os import subprocess import tempfile -from typing import Any +from typing import Any, Dict, List, Union import yaml @@ -85,7 +88,12 @@ def create_file_and_write(file_path: str, content: str) -> bool: return False -def run_command(command, shell: bool = False, cwd: str = None, ignore_codes=None) -> bool: +def run_command( + command, + shell: bool = False, + cwd: Union[str, None] = None, + ignore_codes: Union[List[int], None] = None, +) -> bool: """Run a command and return True if it was successful.""" print(f"Running command: '{command}'") try: @@ -100,12 +108,12 @@ def run_command(command, shell: bool = False, cwd: str = None, ignore_codes=None return False -def run_bash_command(command: str, shell: bool = False, cwd: str = None) -> bool: +def run_bash_command(command: str, shell: bool = False, cwd: Union[str, None] = None) -> bool: """Run a bash command and return True if it was successful.""" return run_command(["bash", "-c", command], shell=shell, cwd=cwd) -def create_temp_file(content: str = None) -> str: +def create_temp_file(content: Union[str, None] = None) -> str: """Create a temporary file and return its path.""" with tempfile.NamedTemporaryFile(delete=False) as tmp_file: if content: @@ -149,3 +157,24 @@ def git_clone(url: str, branch: str, path: str) -> bool: Return True if the clone was successful. """ return run_command(["git", "clone", url, "--branch", branch, path]) + + +def replace_user_name_in_path( + path: str, new_user: str, current_user: str = getpass.getuser() +) -> str: + return path.replace(f"/home/{current_user}", f"/home/{new_user}") + + +def get_display_manager() -> str: + # Command to get the display manager type + cmd = "loginctl show-session $(awk '/tty/ {print $1}' <(loginctl)) -p Type | awk -F= '{print $2}'" + result = subprocess.run(["bash", "-c", cmd], capture_output=True, text=True) + + # Capture the output and remove any trailing newlines or spaces + return result.stdout.strip() + + +def get_filtered_args(args: argparse.Namespace, dataclass_fields: List[Field]) -> Dict[str, Any]: + args_dict = vars(args) + valid_fields = {field.name for field in dataclass_fields} + return {key: args_dict[key] for key in valid_fields if key in args_dict} diff --git a/rtwcli/rtwcli/rtwcli/workspace_manger.py b/rtwcli/rtwcli/rtwcli/workspace_manger.py index 1ffe6767..b44416be 100644 --- a/rtwcli/rtwcli/rtwcli/workspace_manger.py +++ b/rtwcli/rtwcli/rtwcli/workspace_manger.py @@ -17,7 +17,7 @@ import os import re import shutil -from typing import Any, Dict, List +from typing import Any, Dict, List, Tuple, Union import questionary from rtwcli.constants import ( @@ -26,11 +26,12 @@ F_DOCKER_CONTAINER_NAME, F_DOCKER_TAG, F_WS_DOCKER_SUPPORT, + F_WS_NAME, ROS_TEAM_WS_PREFIX, WORKSPACES_KEY, WORKSPACES_PATH, WORKSPACES_PATH_BACKUP_FORMAT, - WS_FOLDER_ENV_VAR, + ROS_TEAM_WS_WS_NAME_ENV_VAR, ) from rtwcli.docker_utils import is_docker_tag_valid from rtwcli.utils import create_file_if_not_exists, load_yaml_file, write_to_yaml_file @@ -40,12 +41,14 @@ class Workspace: """A dataclass representing a workspace.""" + ws_name: str distro: str ws_folder: str ws_docker_support: bool = False - docker_tag: str = None - docker_container_name: str = None - base_ws: str = None + docker_tag: str = "" + docker_container_name: str = "" + base_ws: str = "" + standalone: bool = False def __post_init__(self): self.distro = str(self.distro) @@ -58,6 +61,13 @@ def __post_init__(self): if self.docker_container_name is not None: self.docker_container_name = str(self.docker_container_name) + def to_dict(self) -> Dict[str, Any]: + result = dataclasses.asdict(self) + for key, value in result.items(): + if value == "": + result[key] = None + return result + @dataclasses.dataclass class WorkspacesConfig: @@ -65,34 +75,35 @@ class WorkspacesConfig: workspaces: Dict[str, Workspace] = dataclasses.field(default_factory=dict) + @property + def ws_meta_information(self) -> Dict[str, Any]: + return self.to_dict()[WORKSPACES_KEY] + @classmethod def from_dict(cls, data: Dict[str, Dict[str, dict]]) -> "WorkspacesConfig": if not data: return cls({}) - workspaces = { - ws_name: Workspace(**ws_data) - for ws_name, ws_data in data.get(WORKSPACES_KEY, {}).items() - } + workspaces = {} + for ws_name, ws_data in data.get(WORKSPACES_KEY, {}).items(): + if F_WS_NAME not in ws_data: + ws_data[F_WS_NAME] = ws_name + workspaces[ws_name] = Workspace(**ws_data) return cls(workspaces) def to_dict(self) -> Dict[str, Dict[str, dict]]: - return { - WORKSPACES_KEY: { - ws_name: dataclasses.asdict(ws) for ws_name, ws in self.workspaces.items() - } - } + return {WORKSPACES_KEY: {ws_name: ws.to_dict() for ws_name, ws in self.workspaces.items()}} def get_ws_names(self) -> List[str]: if not self.workspaces: return [] return list(self.workspaces.keys()) - def add_workspace(self, ws_name: str, workspace: Workspace) -> bool: - if ws_name in self.workspaces: - print(f"Workspace '{ws_name}' already exists in the config.") + def add_workspace(self, workspace: Workspace) -> bool: + if workspace.ws_name in self.workspaces: + print(f"Workspace '{workspace.ws_name}' already exists in the config.") return False - self.workspaces[ws_name] = workspace + self.workspaces[workspace.ws_name] = workspace return True @@ -106,15 +117,15 @@ def save_workspaces_config(filepath: str, config: WorkspacesConfig): return write_to_yaml_file(filepath, config.to_dict()) -def update_workspaces_config(config_path: str, ws_name: str, workspace: Workspace) -> bool: +def update_workspaces_config(config_path: str, workspace: Workspace) -> bool: """Update the workspaces config with a new workspace.""" if not create_file_if_not_exists(config_path): print("Could not create workspaces config file. Cannot proceed with porting.") return False workspaces_config = load_workspaces_config_from_yaml_file(config_path) - if not workspaces_config.add_workspace(ws_name, workspace): - print(f"Failed to add workspace '{ws_name}' to the config.") + if not workspaces_config.add_workspace(workspace): + print(f"Failed to add workspace '{workspace.ws_name}' to the config.") return False # Backup current config file @@ -128,11 +139,11 @@ def update_workspaces_config(config_path: str, ws_name: str, workspace: Workspac print(f"Failed to update YAML file '{config_path}'.") return False - print(f"Updated YAML file '{config_path}' with a new workspace '{ws_name}'") + print(f"Updated YAML file '{config_path}' with a new workspace '{workspace.ws_name}'") return True -def get_current_workspace() -> Workspace: +def get_current_workspace() -> Union[Workspace, None]: """Retrieve the current workspace from the workspaces config.""" ws_name = get_current_workspace_name() if not ws_name: @@ -145,14 +156,14 @@ def get_current_workspace() -> Workspace: return workspaces_config.workspaces.get(ws_name, None) -def get_current_workspace_name() -> str: +def get_current_workspace_name() -> Union[str, None]: """Retrieve the current workspace name from the environment variable.""" - ros_ws_folder = os.environ.get(WS_FOLDER_ENV_VAR, None) - if not ros_ws_folder: - print(f"Environment variable '{WS_FOLDER_ENV_VAR}' not set.") + ros_ws_name = os.environ.get(ROS_TEAM_WS_WS_NAME_ENV_VAR, None) + if not ros_ws_name: + print(f"Environment variable '{ROS_TEAM_WS_WS_NAME_ENV_VAR}' not set.") return None - return os.path.basename(ros_ws_folder) + return ros_ws_name def extract_workspaces_from_bash_script(script_path: str) -> Dict[str, dict]: @@ -163,7 +174,7 @@ def extract_workspaces_from_bash_script(script_path: str) -> Dict[str, dict]: workspaces = re.findall(r"(\w+ \(\) \{[^}]+\})", data, re.MULTILINE) workspaces_dict = {} for workspace in workspaces: - ws_name = re.search(r"(\w+) \(", workspace).group(1) + ws_name = re.search(r"(\w+) \(", workspace).group(1) # type: ignore workspaces_dict[ws_name] = {} variables = re.findall(r'(?:export )?(\w+)=(".*?"|\'.*?\'|[^ ]+)', workspace) for var, val in variables: @@ -172,7 +183,7 @@ def extract_workspaces_from_bash_script(script_path: str) -> Dict[str, dict]: return workspaces_dict -def env_var_to_workspace_var(env_var: str, env_var_value: str) -> str: +def env_var_to_workspace_var(env_var: str, env_var_value: Union[str, None]) -> Tuple[str, Any]: """Convert an environment variable to a workspace variable.""" ws_var = env_var.replace(ROS_TEAM_WS_PREFIX, "").lower() if env_var_value == "false": @@ -184,7 +195,7 @@ def env_var_to_workspace_var(env_var: str, env_var_value: str) -> str: return ws_var, ws_var_value -def workspace_var_to_env_var(ws_var: str, ws_var_value: Any) -> str: +def workspace_var_to_env_var(ws_var: str, ws_var_value: Any) -> Tuple[str, str]: """Convert a workspace variable to an environment variable.""" env_var = ROS_TEAM_WS_PREFIX + ws_var.upper() if type(ws_var_value) is bool: @@ -200,7 +211,7 @@ def create_bash_script_content_for_using_ws( """Create a bash script content for using a workspace.""" bash_script_content = "#!/bin/bash\n" - ws_data = dataclasses.asdict(workspace) + ws_data = workspace.to_dict() for ws_var, ws_var_value in ws_data.items(): env_var, env_var_value = workspace_var_to_env_var(ws_var, ws_var_value) bash_script_content += f"export {env_var}='{env_var_value}'\n" @@ -228,6 +239,8 @@ def try_port_workspace(workspace_data_to_port: Dict[str, Any], new_ws_name: str) workspace_data_to_port[F_BASE_WS] = None if F_DOCKER_CONTAINER_NAME not in workspace_data_to_port: workspace_data_to_port[F_DOCKER_CONTAINER_NAME] = None + if F_WS_NAME not in workspace_data_to_port: + workspace_data_to_port[F_WS_NAME] = new_ws_name # validate workspace fields if not workspace_data_to_port[F_WS_DOCKER_SUPPORT]: @@ -273,7 +286,7 @@ def try_port_workspace(workspace_data_to_port: Dict[str, Any], new_ws_name: str) workspace_to_port = Workspace(**workspace_data_to_port) print(f"Updating workspace config in '{WORKSPACES_PATH}'") - success = update_workspaces_config(WORKSPACES_PATH, new_ws_name, workspace_to_port) + success = update_workspaces_config(WORKSPACES_PATH, workspace_to_port) if success: print(f"Updated workspace config in '{WORKSPACES_PATH}'") return True @@ -285,7 +298,7 @@ def try_port_workspace(workspace_data_to_port: Dict[str, Any], new_ws_name: str) def get_compile_cmd( ws_path_abs: str, distro: str, - upstream_ws_abs_path: str = None, + upstream_ws_abs_path: Union[str, None] = None, distro_setup_bash_format: str = "/opt/ros/{distro}/setup.bash", upstream_ws_setup_bash_format: str = "{upstream_ws_abs_path}/install/setup.bash", ) -> List[str]: diff --git a/scripts/environment/setup.bash b/scripts/environment/setup.bash index 72320d4e..6ef8746d 100755 --- a/scripts/environment/setup.bash +++ b/scripts/environment/setup.bash @@ -10,7 +10,7 @@ usage='setup.bash "ros_distro" "workspace_folder" "ros_ws_prefix" "ros_ws_suffix # read -p "Starting..." # Load Framework defines -script_own_dir="$( cd "$( dirname "${BASH_SOURCE[0]}" )" > /dev/null && pwd )" +script_own_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" >/dev/null && pwd)" source $script_own_dir/../_RosTeamWs_Defines.bash source $script_own_dir/../_RosTeamWs_Docker_Defines.bash source $script_own_dir/../_Team_Defines.bash @@ -74,7 +74,6 @@ if [[ $ros_version == 1 ]]; then echo "" echo "RosTeamWS: Sourced file: $WS_FOLDER/devel/setup.bash" - elif [[ $ros_version == 2 ]]; then setup_exports @@ -82,6 +81,13 @@ elif [[ $ros_version == 2 ]]; then setup_ros2_exports setup_ros2_aliases + if [[ $RosTeamWS_WS_DOCKER_SUPPORT == true && $RosTeamWS_STANDALONE == true ]]; then + export ROS_WS=$RosTeamWS_WS_NAME + echo -e "${TERMINAL_COLOR_YELLOW}RosTeamWS: In standalone docker mode there is nothing \ + to source locally. Please switch to docker container first.${TERMINAL_COLOR_NC}" + return + fi + #/opt/rti.com/rti_connext_dds-5.3.1/setenv_ros2rti.bash # export LANG=de_DE.UTF-8 WS_FOLDER="" diff --git a/setup.bash b/setup.bash index 86fd8825..181f2b4c 100644 --- a/setup.bash +++ b/setup.bash @@ -1,4 +1,4 @@ -setup_script_own_dir="$( cd "$( dirname "${BASH_SOURCE[0]}" )" > /dev/null && pwd )" +setup_script_own_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" >/dev/null && pwd)" # Load RosTeamWS defines source $setup_script_own_dir/scripts/_RosTeamWs_Defines.bash @@ -24,16 +24,28 @@ source $setup_script_own_dir/rtwcli/rtwcli/completion/rtw-argcomplete.bash # rtwcli: export ros workspace variables if chosen (rtw workspace use) export ROS_WS_CACHE_SOURCED_TIME=0 function update_ros_ws_variables { - local file_name="/tmp/ros_team_workspace/wokspace_$$.bash" - # If file exists - if [[ -f $file_name ]]; then - local file_mod_time=$(stat -c %Y $file_name) - - # If file was modified after the last source operation - if (( file_mod_time > ROS_WS_CACHE_SOURCED_TIME )); then - source $file_name + local python_constants_file="$setup_script_own_dir/rtwcli/rtwcli/rtwcli/constants.py" + local ws_use_bash_file_path_format + ws_use_bash_file_path_format=$(python3 -c " +import os; import pathlib; import sys +sys.path.insert(0, os.path.dirname('$python_constants_file')) +from rtwcli.constants import WS_USE_BASH_FILE_PATH_FORMAT +print(WS_USE_BASH_FILE_PATH_FORMAT)") + + if [[ -z $ws_use_bash_file_path_format ]]; then + echo "Error: Could not get WS_USE_BASH_FILE_PATH_FORMAT from $python_constants_file" + else + local file_name="${ws_use_bash_file_path_format//\{ppid\}/$$}" + if [[ -f $file_name ]]; then # If file exists + local file_mod_time + file_mod_time=$(stat -c %Y $file_name) + + # If file was modified after the last source operation + if ((file_mod_time > ROS_WS_CACHE_SOURCED_TIME)); then + source "$file_name" ROS_WS_CACHE_SOURCED_TIME=$file_mod_time export ROS_WS_CACHE_SOURCED_TIME + fi fi fi }