diff --git a/riocli/compose/defaults.py b/riocli/compose/defaults.py index 012d7c1f..5bbc8b8f 100644 --- a/riocli/compose/defaults.py +++ b/riocli/compose/defaults.py @@ -7,13 +7,24 @@ ROS_MASTER_IMAGE = "quay.io/rapyuta/ros-base-melodic:master" ROS_MASTER_PORT = 1234 ROS_MASTER_CONTAINER_NAME = "roscore" -DEFAULT_VOLUME_MOUNTS = [ - "/opt/rapyuta/configs:/opt/rapyuta/configs:rslave", - "/var/log/riouser:/var/log/riouser:rslave", - "/var/log/rapyuta/deployments:/var/log/rapyuta/deployments:rslave", - "/var/lib/docker/containers:/var/lib/docker/containers:rslave", - "/dev:/dev:rslave", -] +CONFIGS_DIR = "/opt/rapyuta/configs" + + +def get_default_volume_mounts(configs_path: str | None = None) -> list[str]: + """ + Returns the default volume mounts for a Docker Compose service. + + Args: + configs_path: Host-side path to bind-mount at CONFIGS_DIR instead of + CONFIGS_DIR itself (i.e. an override for the `/opt/rapyuta/configs` host path). + """ + return [ + f"{configs_path or CONFIGS_DIR}:{CONFIGS_DIR}:rslave", + "/var/log/riouser:/var/log/riouser:rslave", + "/var/log/rapyuta/deployments:/var/log/rapyuta/deployments:rslave", + "/var/lib/docker/containers:/var/lib/docker/containers:rslave", + "/dev:/dev:rslave", + ] def generate_roscore_service() -> Service: diff --git a/riocli/compose/down.py b/riocli/compose/down.py index 0ca86abf..dce79e5b 100644 --- a/riocli/compose/down.py +++ b/riocli/compose/down.py @@ -57,6 +57,28 @@ default=False, help="Treat the argument as a chart name and resolve inputs from it.", ) +@click.option( + "--configs-path", + "configs_path", + default=None, + help="Host path to bind-mount in place of /opt/rapyuta/configs in the generated compose " + "file. Only takes effect when the compose file has to be (re)generated because it's " + "missing or empty -- pass the same value used with `rio compose up`/`generate` so a " + "regenerated file doesn't fall back to the device paths.", + type=click.Path( + exists=True, dir_okay=True, file_okay=False, path_type=Path, resolve_path=True + ), +) +@click.option( + "--ignore-volume-source", + "ignore_volume_source", + multiple=True, + default=(), + help="gitignore-style pattern matched against a volume's full host-side path -- drops " + "the bind entirely instead of mounting it. Only takes effect when the compose file has " + "to be (re)generated; see --configs-path. Repeatable; evaluated in order, last match " + "wins; prefix with '!' to re-include a path an earlier pattern excluded.", +) @click.argument("files", nargs=-1) @click.pass_context def down( @@ -67,12 +89,16 @@ def down( path: str, use_chart: bool, files: tuple[str, ...], + configs_path: Path | None = None, + ignore_volume_source: tuple[str, ...] = (), ): """ Stop and remove services defined in the Docker Compose file. If the compose file does not exist, it will be generated using the provided manifest(s), - values, and secret files before bringing the services down. + values, and secret files before bringing the services down. Pass --configs-path and/or + --ignore-volume-source in that case if you used them with `up`/`generate`, so the + regenerated file doesn't fall back to the un-overridden device paths. Examples: @@ -113,6 +139,8 @@ def down( values=values, secrets=secrets, files=files, + configs_path=configs_path.as_posix() if configs_path else None, + ignore_volume_source=ignore_volume_source, ) write_compose_yaml(output_path=compose_path, compose_dict=compose_doc) diff --git a/riocli/compose/generate.py b/riocli/compose/generate.py index cbb2ddf4..02491749 100644 --- a/riocli/compose/generate.py +++ b/riocli/compose/generate.py @@ -81,6 +81,31 @@ default=False, help="Merge new services into existing compose file instead of overwriting.", ) +@click.option( + "--configs-path", + "configs_path", + default=None, + help="Host path to bind-mount in place of /opt/rapyuta/configs in the generated compose " + "file. Volumes redirected here are skipped by the init-fixperms permission fixup, since " + "they now point at your own local files rather than the device.", + type=click.Path( + exists=True, dir_okay=True, file_okay=False, path_type=Path, resolve_path=True + ), +) +@click.option( + "--ignore-volume-source", + "ignore_volume_source", + multiple=True, + default=(), + help="gitignore-style pattern matched against a volume's full host-side path, as declared " + "in the manifest's subPath (before any --configs-path rewrite) -- drops the bind entirely " + "instead of mounting it. Applies to deployment-declared volumes and to the default mounts " + "other than the /opt/rapyuta/configs bind, which --configs-path controls instead. " + "Repeatable; evaluated in order, last match wins; prefix with '!' to re-include a path an " + "earlier pattern excluded (e.g. --ignore-volume-source '/opt/rapyuta/configs/station/*' " + "--ignore-volume-source '!/opt/rapyuta/configs/station/sim-nginx.conf.template'). " + "Independent of --configs-path -- applies whether or not that flag is also given.", +) @click.argument("files", nargs=-1) @click.pass_context def generate( @@ -93,6 +118,8 @@ def generate( append_services: bool, files: tuple[str, ...], branch: str = None, + configs_path: Path | None = None, + ignore_volume_source: tuple[str, ...] = (), ) -> None: """ Convert Rapyuta.io manifests into a Docker Compose YAML file. @@ -122,6 +149,23 @@ def generate( rio compose generate templates/ rio compose generate --chart --append ioconfig-syncer + + Bind-mount a local directory in place of /opt/rapyuta/configs: + + rio compose generate templates/ --configs-path ./local-configs + + Bind-mount a local directory but drop binds under a sub-path entirely + (e.g. no local equivalent exists for it): + + rio compose generate templates/ --configs-path ./local-configs \\ + --ignore-volume-source '/opt/rapyuta/configs/auth/*' \\ + --ignore-volume-source '/opt/rapyuta/configs/station/*' \\ + --ignore-volume-source '!/opt/rapyuta/configs/station/sim-nginx.conf.template' + + Drop a bind entirely without redirecting anything else (independent of + --configs-path): + + rio compose generate templates/ --ignore-volume-source '/opt/rapyuta/configs/auth/*' """ if not path: @@ -150,6 +194,8 @@ def generate( values=values, secrets=secrets, files=files, + configs_path=configs_path.as_posix() if configs_path else None, + ignore_volume_source=ignore_volume_source, ) if append_services and existing_services: compose_doc["services"] = merge_compose_services( @@ -166,6 +212,8 @@ def generate_compose_file( values: tuple[str, ...], secrets: tuple[str, ...], files: tuple[str, ...], + configs_path: str | None = None, + ignore_volume_source: tuple[str, ...] = (), ) -> dict: glob_files, abs_values, abs_secrets = process_files_values_secrets( files, values, secrets @@ -183,7 +231,11 @@ def generate_compose_file( print_centered_text("Converting Manifests") docker_compose_manifest = populate( - ctx=ctx, deployments=deployments, packages=packages + ctx=ctx, + deployments=deployments, + packages=packages, + configs_path=configs_path, + ignore_volume_source=ignore_volume_source, ) return clean_dict(asdict(docker_compose_manifest)) diff --git a/riocli/compose/populate.py b/riocli/compose/populate.py index 1d184fc1..2cbe6812 100644 --- a/riocli/compose/populate.py +++ b/riocli/compose/populate.py @@ -1,15 +1,20 @@ from __future__ import annotations +import fnmatch import shlex -from typing import Any +from typing import TYPE_CHECKING, Any import click from munch import Munch +if TYPE_CHECKING: + from collections.abc import Sequence + from riocli.compose.defaults import ( CLOUD_RUNTIME, - DEFAULT_VOLUME_MOUNTS, + CONFIGS_DIR, generate_roscore_service, + get_default_volume_mounts, ) from riocli.compose.model import DependsCondition, DockerCompose, HealthCheck, Service from riocli.constants.colors import Colors @@ -33,6 +38,8 @@ def populate( ctx: click.Context, deployments: dict[str, dict], packages: dict[str, dict], + configs_path: str | None = None, + ignore_volume_source: Sequence[str] = (), *args, **kwargs, ) -> DockerCompose: @@ -42,6 +49,11 @@ def populate( Args: deployments: Dictionary of deployment definitions. packages: Dictionary of package definitions. + configs_path: Host-side path to bind-mount in place of CONFIGS_DIR + wherever CONFIGS_DIR appears as a volume's host path. + ignore_volume_source: gitignore-style patterns matched against a volume's + full host-side path -- binds matching one are dropped entirely. + Independent of configs_path; applies whether or not it's also given. Returns: DockerCompose object representing the final configuration. @@ -61,6 +73,8 @@ def populate( packages=packages, services=services, named_volumes=named_volumes, + configs_path=configs_path, + ignore_volume_source=ignore_volume_source, spinner=spinner, ) processed_deployments[key] = deployment @@ -76,7 +90,9 @@ def populate( spinner.text = click.style("Conversion successful.", fg=Colors.BRIGHT_GREEN) spinner.green.ok(Symbols.SUCCESS) - fixup_vols = get_volumes_requiring_fixup(processed_deployments) + fixup_vols = get_volumes_requiring_fixup( + processed_deployments, configs_path, ignore_volume_source + ) if fixup_vols: fix_cmds = [_build_fixup_cmd(entry) for entry in fixup_vols] fixperms_vols = [ @@ -95,9 +111,7 @@ def populate( if name == "init-fixperms": continue if any( - isinstance(vol, str) - and len(vol.split(":")) >= 2 - and vol.split(":")[1] in affected_paths + isinstance(vol, str) and _get_volume_target(vol) in affected_paths for vol in getattr(svc, "volumes", []) ): if svc.depends_on is None: @@ -160,14 +174,103 @@ def _build_fixup_cmd(entry: dict) -> str: return f"if [ -f {path} ]; then {file_block}; else {dir_block}; fi" -def get_volumes_requiring_fixup(deployments: dict[str, dict]) -> list[dict]: +def _get_volume_target(vol: str) -> str | None: + """Extracts the container-side mount path from a compose volume string. + + Parses from the right, not `vol.split(":")[1]`, so a host path containing + a literal ':' doesn't shift the field positions -- e.g. a Compose short + volume syntax quirk rather than a Windows accommodation (this project does + not target Windows); relevant now that --configs-path lets a user point + at any host directory name, including one with a colon in it. The trailing + segment is treated as a mode ("rw", "ro", "rslave", ...) rather than the + container path whenever it doesn't look like an absolute path. + """ + parts = vol.split(":") + if len(parts) < 2: + return None + if len(parts) >= 3 and not parts[-1].startswith("/"): + return parts[-2] + return parts[-1] + + +def _is_ignored_volume_source(host_path: str, ignore_patterns: Sequence[str]) -> bool: + """gitignore-style match against a volume's full host-side path. + + Patterns are evaluated in order, last match wins; a leading '!' negates a + preceding match (so a later, more specific pattern can re-include a path an + earlier, broader pattern excluded -- e.g. ["/opt/rapyuta/configs/station/*", + "!/opt/rapyuta/configs/station/sim-nginx.conf.template"]). A pattern matches + host_path itself (fnmatch-style glob) or, treated as a directory prefix, any + path under it. Not scoped to CONFIGS_DIR or configs_path in any way -- any + absolute host path a deployment declares as a volume source is a valid + pattern target. + """ + ignored = False + for raw in ignore_patterns: + negate = raw.startswith("!") + pattern = (raw[1:] if negate else raw).rstrip("/") + if not pattern: + continue + if fnmatch.fnmatch(host_path, pattern) or host_path.startswith(pattern + "/"): + ignored = not negate + return ignored + + +def _under_configs_dir(host_path: str) -> bool: + """True if host_path is CONFIGS_DIR itself or a path inside it.""" + return host_path == CONFIGS_DIR or host_path.startswith(CONFIGS_DIR + "/") + + +def _substitute_configs_path( + host_path: str | None, + configs_path: str | None, + ignore_patterns: Sequence[str] = (), +) -> str | None: + """Rewrites a volume's host path from under CONFIGS_DIR to under configs_path, if given. + + Returns None (signalling "drop this volume") when host_path matches one of + ignore_patterns -- lets callers omit binds that have no local equivalent + instead of pointing them at a directory that doesn't exist. Independent of + configs_path: ignoring and redirecting are two separate operations on the + same bind, so ignore_patterns is checked first and applies whether or not + configs_path is given. + """ + if not host_path: + return host_path + if ignore_patterns and _is_ignored_volume_source(host_path, ignore_patterns): + return None + if not configs_path: + return host_path + if host_path == CONFIGS_DIR: + return configs_path + if _under_configs_dir(host_path): + return configs_path.rstrip("/") + host_path[len(CONFIGS_DIR) :] + return host_path + + +def get_volumes_requiring_fixup( + deployments: dict[str, dict], + configs_path: str | None = None, + ignore_volume_source: Sequence[str] = (), +) -> list[dict]: + """Collects volumes declaring uid/gid/perm into init-fixperms fixup entries. + + Skips any volume whose subPath was redirected under configs_path: that + bind now points at the developer's own local directory, and running + chown/chmod as root against it (as init-fixperms does for real device + paths) would change ownership/mode of the developer's files rather than + fixing up a device path -- see init-fixperms's root user and _build_fixup_cmd. + """ volumes_by_path: dict[tuple, dict] = {} for dep in deployments.values(): for volume in dep.spec.get("volumes", []): uid, gid, perm = volume.get("uid"), volume.get("gid"), volume.get("perm") if uid is None and gid is None and perm is None: continue - host = volume.get("subPath") + sub_path = volume.get("subPath") + if configs_path and sub_path and _under_configs_dir(sub_path): + continue + host = _substitute_configs_path(sub_path, configs_path, ignore_volume_source) container = volume.get("mountPath") if not host or not container: continue @@ -228,6 +331,8 @@ def _process_deployment_services( packages: dict[str, dict], services: dict[str, Service], named_volumes: set[str], + configs_path: str | None = None, + ignore_volume_source: Sequence[str] = (), spinner=None, ) -> None: """Process a single deployment and add its services to the services dictionary. @@ -252,7 +357,9 @@ def _process_deployment_services( restart_policy = "no" # Build volume mounts, dependencies, and environment variables - volume_mounts = build_volume_mounts(deployment, named_volumes, spinner=spinner) + volume_mounts = build_volume_mounts( + deployment, named_volumes, configs_path, ignore_volume_source, spinner=spinner + ) ros_enabled = _is_ros_enabled(deployment=deployment, package=package) if ros_enabled and "ros-master" not in services: services["ros-master"] = generate_roscore_service() @@ -322,7 +429,11 @@ def create_service( def build_volume_mounts( - deployment: dict, named_volumes: set[str], spinner=None + deployment: dict, + named_volumes: set[str], + configs_path: str | None = None, + ignore_volume_source: Sequence[str] = (), + spinner=None, ) -> list[str]: """ Constructs a list of volume mount strings for a given deployment. @@ -349,14 +460,34 @@ def build_volume_mounts( Args: deployment: The deployment definition dictionary. named_volumes: Mutable set collecting disk-backed named-volume names. + configs_path: Host-side path to bind-mount in place of CONFIGS_DIR + wherever CONFIGS_DIR appears as a volume's host path (device + bind mounts only; cloud disk mounts have no host path). + ignore_volume_source: gitignore-style patterns matched against a volume's + full host-side path -- binds matching one are dropped entirely. + Independent of configs_path; applies whether or not it's also given. spinner: Optional spinner used to surface subPath warnings. Returns: List of Docker volume mount strings. """ - # Device runtime gets the standard host mounts; cloud starts empty. + # Device runtime gets the standard host mounts (optionally redirected under + # configs_path, with ignore_volume_source able to drop individual default + # mounts other than the whole-tree CONFIGS_DIR bind -- see + # test_default_top_level_mount_unaffected_by_ignore); cloud starts empty. runtime = deployment.spec.get("runtime") - service_volumes = [] if runtime == CLOUD_RUNTIME else DEFAULT_VOLUME_MOUNTS.copy() + if runtime == CLOUD_RUNTIME: + service_volumes = [] + else: + service_volumes = get_default_volume_mounts(configs_path) + if ignore_volume_source: + service_volumes = service_volumes[:1] + [ + vol + for vol in service_volumes[1:] + if not _is_ignored_volume_source( + vol.split(":", 1)[0], ignore_volume_source + ) + ] # Add custom volumes from deployment for volume in deployment.spec.get("volumes", []): @@ -385,8 +516,12 @@ def build_volume_mounts( named_volumes.add(disk_name) continue - # Device bind mount -> host path mounted at container path. - src = volume.get("subPath") + # Device bind mount -> host path mounted at container path, subject to + # the same configs_path redirect / ignore_volume_source drop as the + # default CONFIGS_DIR mount above. + src = _substitute_configs_path( + volume.get("subPath"), configs_path, ignore_volume_source + ) if not src: continue diff --git a/riocli/compose/up.py b/riocli/compose/up.py index a117a5f8..b9deda45 100644 --- a/riocli/compose/up.py +++ b/riocli/compose/up.py @@ -70,6 +70,30 @@ default=False, help="Treat the argument as a chart name instead of a file path.", ) +@click.option( + "--configs-path", + "configs_path", + default=None, + help="Host path to bind-mount in place of /opt/rapyuta/configs in the generated compose " + "file. Volumes redirected here are skipped by the init-fixperms permission fixup, since " + "they now point at your own local files rather than the device.", + type=click.Path( + exists=True, dir_okay=True, file_okay=False, path_type=Path, resolve_path=True + ), +) +@click.option( + "--ignore-volume-source", + "ignore_volume_source", + multiple=True, + default=(), + help="gitignore-style pattern matched against a volume's full host-side path, as declared " + "in the manifest's subPath (before any --configs-path rewrite) -- drops the bind entirely " + "instead of mounting it. Applies to deployment-declared volumes and to the default mounts " + "other than the /opt/rapyuta/configs bind, which --configs-path controls instead. " + "Repeatable; evaluated in order, last match wins; prefix with '!' to re-include a path an " + "earlier pattern excluded. Independent of --configs-path -- applies whether or not that " + "flag is also given.", +) @click.argument("files", nargs=-1) @click.pass_context def up( @@ -82,6 +106,8 @@ def up( build: bool, use_chart: bool, files: tuple[str, ...], + configs_path: Path | None = None, + ignore_volume_source: tuple[str, ...] = (), ): """ Generate and start services using Docker Compose. @@ -112,6 +138,10 @@ def up( Generate from a chart and start services: rio compose up --chart ioconfig-syncer -v my-values.yaml + + Bind-mount a local directory in place of /opt/rapyuta/configs: + + rio compose up templates/ --configs-path ./local-configs """ chart_obj = None @@ -129,6 +159,8 @@ def up( files=files, values=values, secrets=secrets, + configs_path=configs_path.as_posix() if configs_path else None, + ignore_volume_source=ignore_volume_source, ) write_compose_yaml(output_path=compose_path, compose_dict=compose_doc) diff --git a/tests/unit/compose/test_generate.py b/tests/unit/compose/test_generate.py index ff0d1c79..7e903cab 100644 --- a/tests/unit/compose/test_generate.py +++ b/tests/unit/compose/test_generate.py @@ -107,6 +107,32 @@ def test_no_user_values_only_chart_values( assert values[0].endswith("values.yaml") +class TestGenerateCommandIgnoreVolumeSource: + @patch("riocli.compose.generate.write_compose_yaml") + @patch("riocli.compose.generate.generate_compose_file") + def test_ignore_volume_source_accepted_without_configs_path( + self, mock_gen, mock_write, tmp_path + ): + mock_gen.return_value = {"services": {}} + runner = CliRunner() + result = runner.invoke( + generate, + [ + "-p", + str(tmp_path), + "--ignore-volume-source", + "/opt/rapyuta/configs/auth/*", + "some-manifest.yaml", + ], + ) + assert result.exit_code == 0, result.output + mock_gen.assert_called_once() + assert mock_gen.call_args.kwargs["configs_path"] is None + assert mock_gen.call_args.kwargs["ignore_volume_source"] == ( + "/opt/rapyuta/configs/auth/*", + ) + + class TestGenerateCommandChartFlag: def test_chart_flag_requires_chart_name(self, tmp_path): runner = CliRunner() diff --git a/tests/unit/compose/test_populate.py b/tests/unit/compose/test_populate.py index d3325ff4..cd138127 100644 --- a/tests/unit/compose/test_populate.py +++ b/tests/unit/compose/test_populate.py @@ -5,16 +5,18 @@ import subprocess from dataclasses import asdict from types import SimpleNamespace +from unittest.mock import MagicMock import pytest from munch import Munch, munchify -from riocli.compose.defaults import DEFAULT_VOLUME_MOUNTS from riocli.compose.generate import clean_dict from riocli.compose.populate import ( _build_fixup_cmd, + _substitute_configs_path, build_volume_mounts, find_package, + get_default_volume_mounts, get_volumes_requiring_fixup, populate, populate_command, @@ -66,6 +68,282 @@ def test_entrypoint_and_command_coexist(self): assert populate_command(exe) == "--foo bar" +class TestPopulateHealthcheck: + def test_no_liveness_probe_returns_none(self): + assert populate_healthcheck(munchify({})) is None + + def test_no_exec_command_returns_none(self): + exe = munchify({"livenessProbe": {"exec": {}}}) + assert populate_healthcheck(exe) is None + + def test_initial_delay_seconds_becomes_start_period(self): + exe = munchify( + { + "livenessProbe": { + "exec": {"command": ["rosnode", "list"]}, + "initialDelaySeconds": 45, + } + } + ) + hc = populate_healthcheck(exe) + assert hc.start_period == "45s" + + def test_missing_initial_delay_seconds_leaves_start_period_none(self): + exe = munchify({"livenessProbe": {"exec": {"command": ["rosnode", "list"]}}}) + hc = populate_healthcheck(exe) + assert hc.start_period is None + + +class TestSubstituteConfigsPath: + def test_no_configs_path_leaves_host_path_unchanged(self): + assert ( + _substitute_configs_path("/opt/rapyuta/configs/wms/settings.yaml", None) + == "/opt/rapyuta/configs/wms/settings.yaml" + ) + + def test_none_host_path_passthrough(self): + assert _substitute_configs_path(None, "/local") is None + + def test_exact_configs_dir_rewritten(self): + assert _substitute_configs_path("/opt/rapyuta/configs", "/local") == "/local" + + def test_subpath_rewritten_preserving_suffix(self): + assert ( + _substitute_configs_path("/opt/rapyuta/configs/wms/settings.yaml", "/local") + == "/local/wms/settings.yaml" + ) + + def test_path_outside_configs_dir_untouched(self): + assert ( + _substitute_configs_path("/var/spool/print/csv", "/local") + == "/var/spool/print/csv" + ) + + def test_ignore_pattern_drops_matching_subpath(self): + assert ( + _substitute_configs_path( + "/opt/rapyuta/configs/auth/openid-client.json", + "/local", + ["/opt/rapyuta/configs/auth/*"], + ) + is None + ) + + def test_ignore_pattern_directory_style_prefix_match(self): + assert ( + _substitute_configs_path( + "/opt/rapyuta/configs/maps/site.yaml", + "/local", + ["/opt/rapyuta/configs/maps"], + ) + is None + ) + + def test_ignore_pattern_exact_directory_itself_matches(self): + assert ( + _substitute_configs_path( + "/opt/rapyuta/configs/maps", "/local", ["/opt/rapyuta/configs/maps"] + ) + is None + ) + + def test_ignore_pattern_matches_any_absolute_path_not_just_configs_dir(self): + """Not scoped to CONFIGS_DIR at all -- any absolute host path a + deployment declares is a valid pattern target.""" + assert ( + _substitute_configs_path("/var/lib/minio/", "/local", ["/var/lib/minio/*"]) + is None + ) + assert ( + _substitute_configs_path("/var/lib/minio", "/local", ["/var/lib/minio"]) + is None + ) + + def test_ignore_pattern_non_matching_subpath_still_rewritten(self): + assert ( + _substitute_configs_path( + "/opt/rapyuta/configs/wms/settings.yaml", + "/local", + ["/opt/rapyuta/configs/auth/*"], + ) + == "/local/wms/settings.yaml" + ) + + def test_negated_pattern_reincludes_specific_file(self): + patterns = [ + "/opt/rapyuta/configs/station/*", + "!/opt/rapyuta/configs/station/sim-nginx.conf.template", + ] + assert ( + _substitute_configs_path( + "/opt/rapyuta/configs/station/sim-nginx.conf.template", + "/local", + patterns, + ) + == "/local/station/sim-nginx.conf.template" + ) + assert ( + _substitute_configs_path( + "/opt/rapyuta/configs/station/station.launch", "/local", patterns + ) + is None + ) + + def test_last_match_wins_when_negation_precedes_broader_pattern(self): + # Order matters: a later broader drop re-excludes what an earlier + # negation re-included. + patterns = [ + "!/opt/rapyuta/configs/station/sim-nginx.conf.template", + "/opt/rapyuta/configs/station/*", + ] + assert ( + _substitute_configs_path( + "/opt/rapyuta/configs/station/sim-nginx.conf.template", + "/local", + patterns, + ) + is None + ) + + def test_ignore_pattern_drops_even_without_configs_path(self): + """Ignoring and redirecting are independent operations on the same + bind -- dropping a matched path must work with no configs_path at all.""" + assert ( + _substitute_configs_path( + "/opt/rapyuta/configs/auth/openid-client.json", + None, + ["/opt/rapyuta/configs/auth/*"], + ) + is None + ) + + def test_non_matching_path_left_alone_without_configs_path(self): + assert ( + _substitute_configs_path( + "/opt/rapyuta/configs/wms/settings.yaml", + None, + ["/opt/rapyuta/configs/auth/*"], + ) + == "/opt/rapyuta/configs/wms/settings.yaml" + ) + + +class TestBuildVolumeMountsWithIgnore: + def test_ignored_custom_volume_is_omitted(self): + dep = munchify( + { + "spec": { + "volumes": [ + { + "subPath": "/opt/rapyuta/configs/auth/openid-client.json", + "mountPath": "/usr/share/caddy/openid-client.json", + }, + { + "subPath": "/opt/rapyuta/configs/wms/settings.yaml", + "mountPath": "/opt/rapyuta/configs/wms/settings.yaml", + }, + ] + } + } + ) + volumes = build_volume_mounts( + dep, set(), "/local", ["/opt/rapyuta/configs/auth/*"] + ) + assert not any("openid-client.json" in v for v in volumes) + assert any( + v == "/local/wms/settings.yaml:/opt/rapyuta/configs/wms/settings.yaml" + for v in volumes + ) + + def test_ignore_works_without_configs_path_too(self): + """A deployment running against a device-like setup where + /opt/rapyuta/configs genuinely exists can still drop a specific + sub-path (e.g. an unprovisioned secret) without redirecting anything.""" + dep = munchify( + { + "spec": { + "volumes": [ + { + "subPath": "/opt/rapyuta/configs/auth/openid-client.json", + "mountPath": "/usr/share/caddy/openid-client.json", + }, + { + "subPath": "/opt/rapyuta/configs/wms/settings.yaml", + "mountPath": "/opt/rapyuta/configs/wms/settings.yaml", + }, + ] + } + } + ) + volumes = build_volume_mounts(dep, set(), None, ["/opt/rapyuta/configs/auth/*"]) + assert not any("openid-client.json" in v for v in volumes) + assert ( + "/opt/rapyuta/configs/wms/settings.yaml:/opt/rapyuta/configs/wms/settings.yaml" + in volumes + ) + + def test_default_top_level_mount_unaffected_by_ignore(self): + """The blanket /opt/rapyuta/configs default mount is a single whole-tree + bind, not an enumerable per-file volume -- ignore patterns only apply + to individually declared deployment volumes.""" + dep = munchify({"spec": {"volumes": []}}) + volumes = build_volume_mounts( + dep, + set(), + "/local", + ["/opt/rapyuta/configs/auth/*", "/opt/rapyuta/configs/maps"], + ) + assert "/local:/opt/rapyuta/configs:rslave" in volumes + + +class TestGetDefaultVolumeMounts: + def test_configs_path_overrides_default_mount_source(self): + volumes = get_default_volume_mounts("/local") + assert "/local:/opt/rapyuta/configs:rslave" in volumes + + def test_no_configs_path_uses_literal_configs_dir(self): + volumes = get_default_volume_mounts(None) + assert "/opt/rapyuta/configs:/opt/rapyuta/configs:rslave" in volumes + + +class TestGetVolumesRequiringFixupWithIgnore: + def test_ignored_fixup_volume_is_dropped(self): + dep = _make_deployment( + [ + { + "subPath": "/opt/rapyuta/configs/auth/openid-client.json", + "mountPath": "/usr/share/caddy/openid-client.json", + "uid": 1000, + "gid": 1000, + "perm": 644, + }, + ] + ) + result = get_volumes_requiring_fixup( + {"dep": dep}, + configs_path="/local", + ignore_volume_source=["/opt/rapyuta/configs/auth/*"], + ) + assert result == [] + + def test_ignored_fixup_volume_is_dropped_without_configs_path(self): + dep = _make_deployment( + [ + { + "subPath": "/opt/rapyuta/configs/auth/openid-client.json", + "mountPath": "/usr/share/caddy/openid-client.json", + "uid": 1000, + "gid": 1000, + "perm": 644, + }, + ] + ) + result = get_volumes_requiring_fixup( + {"dep": dep}, ignore_volume_source=["/opt/rapyuta/configs/auth/*"] + ) + assert result == [] + + class TestGetVolumesRequiringFixup: def test_deduplicates_same_host_and_container(self): dep = _make_deployment( @@ -375,7 +653,7 @@ def test_defaults_always_present(self): dep = _make_deployment([]) named: set[str] = set() mounts = build_volume_mounts(dep, named) - assert mounts == DEFAULT_VOLUME_MOUNTS + assert mounts == get_default_volume_mounts() assert named == set() def test_device_bind_mount_unchanged(self): @@ -413,13 +691,13 @@ def test_disk_volume_without_name_is_skipped(self): dep = _make_deployment([{"mountPath": "/data", "depends": {"kind": "disk"}}]) named: set[str] = set() mounts = build_volume_mounts(dep, named) - assert mounts == DEFAULT_VOLUME_MOUNTS + assert mounts == get_default_volume_mounts() assert named == set() def test_volume_without_mountpath_is_skipped(self): dep = _make_deployment([{"subPath": "/host/data"}]) mounts = build_volume_mounts(dep, set()) - assert mounts == DEFAULT_VOLUME_MOUNTS + assert mounts == get_default_volume_mounts() def test_disk_kind_capitalized_becomes_named_volume(self): # SDK DiskDepends defaults to "Disk"; the capitalized spelling must match too. @@ -467,7 +745,15 @@ def test_cloud_runtime_omits_default_mounts(self): def test_device_runtime_keeps_default_mounts(self): dep = munchify({"spec": {"runtime": "device", "volumes": []}}) - assert build_volume_mounts(dep, set()) == DEFAULT_VOLUME_MOUNTS + assert build_volume_mounts(dep, set()) == get_default_volume_mounts() + + def test_configs_path_redirects_default_and_device_bind_mounts(self): + dep = _make_deployment( + [{"subPath": "/opt/rapyuta/configs/wms/settings.yaml", "mountPath": "/data"}] + ) + mounts = build_volume_mounts(dep, set(), "/local") + assert "/local:/opt/rapyuta/configs:rslave" in mounts + assert "/local/wms/settings.yaml:/data" in mounts def test_subpath_on_disk_mount_warns_but_mounts_whole_volume(self): class _RecordingSpinner: @@ -492,7 +778,7 @@ def write(self, text): assert any("subPath" in w and "sub" in w for w in spinner.writes) -class TestPopulateHealthcheck: +class TestPopulateHealthcheckProbes: def test_no_probe_returns_none(self): assert populate_healthcheck(munchify({})) is None @@ -652,6 +938,104 @@ def test_no_top_level_volumes_without_disk_mounts(self): assert "volumes" not in clean_dict(asdict(compose)) +class TestPopulateFixpermsDependsOnWiring: + def test_service_declaring_fixup_volume_depends_on_init_fixperms(self): + """End-to-end through populate(): a deployment volume declaring + uid/gid/perm must make its service wait on init-fixperms, not just + produce a fixup entry -- covers the depends_on wiring loop that no + other test drives through populate() itself.""" + package = munchify( + { + "metadata": {"version": "1.0"}, + "spec": { + "executables": [ + {"name": "server", "docker": {"image": "acme/server:1.0"}} + ], + }, + } + ) + deployment = munchify( + { + "metadata": { + "name": "dep1", + "depends": {"nameOrGUID": "server-pkg", "version": "1.0"}, + }, + "spec": { + "volumes": [ + { + "subPath": "/host/settings.yaml", + "mountPath": "/app/settings.yaml", + "uid": 1000, + "gid": 1000, + "perm": 644, + } + ], + }, + } + ) + deployments = {"deployment:dep1": deployment} + packages = {"package:server-pkg:1.0": package} + ctx = MagicMock(obj=MagicMock(data={})) + + result = populate(ctx=ctx, deployments=deployments, packages=packages) + + assert "init-fixperms" in result.services + service = result.services["dep1_server"] + assert ( + service.depends_on["init-fixperms"].condition + == "service_completed_successfully" + ) + + def test_service_with_configs_path_redirected_volume_has_no_depends_on(self): + """A volume redirected under --configs-path is the developer's own + local file, not a device path -- it must not trigger init-fixperms + (which would chown/chmod that local file as root), so the service + gets no depends_on edge for it either.""" + package = munchify( + { + "metadata": {"version": "1.0"}, + "spec": { + "executables": [ + {"name": "server", "docker": {"image": "acme/server:1.0"}} + ], + }, + } + ) + deployment = munchify( + { + "metadata": { + "name": "dep1", + "depends": {"nameOrGUID": "server-pkg", "version": "1.0"}, + }, + "spec": { + "volumes": [ + { + "subPath": "/opt/rapyuta/configs/settings.yaml", + "mountPath": "/app/settings.yaml", + "uid": 1000, + "gid": 1000, + "perm": 644, + } + ], + }, + } + ) + deployments = {"deployment:dep1": deployment} + packages = {"package:server-pkg:1.0": package} + ctx = MagicMock(obj=MagicMock(data={})) + + result = populate( + ctx=ctx, + deployments=deployments, + packages=packages, + configs_path="/local", + ) + + assert "init-fixperms" not in result.services + service = result.services["dep1_server"] + assert not service.depends_on + + class TestFindPackage: """Packages are keyed by name and version, so a manifest may legitimately carry more than one version of the same Package.""" diff --git a/tests/unit/compose/test_up.py b/tests/unit/compose/test_up.py index 368f4095..fd55db7f 100644 --- a/tests/unit/compose/test_up.py +++ b/tests/unit/compose/test_up.py @@ -5,6 +5,38 @@ from riocli.compose.up import up +class TestUpCommandIgnoreVolumeSource: + @patch("riocli.compose.up.DockerComposeManager") + @patch("riocli.compose.up.write_compose_yaml") + @patch("riocli.compose.up.generate_compose_file") + def test_ignore_volume_source_accepted_without_configs_path( + self, mock_gen, mock_write, mock_mgr_cls, tmp_path + ): + mock_gen.return_value = {"services": {}} + mgr = MagicMock() + mgr.validate_docker_availability.return_value = True + mgr.up.return_value = True + mock_mgr_cls.return_value = mgr + + runner = CliRunner() + result = runner.invoke( + up, + [ + "-p", + str(tmp_path), + "--ignore-volume-source", + "/opt/rapyuta/configs/auth/*", + "some-manifest.yaml", + ], + ) + assert result.exit_code == 0, result.output + mock_gen.assert_called_once() + assert mock_gen.call_args.kwargs["configs_path"] is None + assert mock_gen.call_args.kwargs["ignore_volume_source"] == ( + "/opt/rapyuta/configs/auth/*", + ) + + class TestUpCommandChartFlag: def test_chart_flag_requires_chart_name(self, tmp_path): runner = CliRunner()