rosotacom-dev 2.5.dev52__py3-none-any.whl → 2.5.dev53__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
rosotacom/cli.py CHANGED
@@ -64,6 +64,7 @@ from .image_cache import (
64
64
  image_fingerprint,
65
65
  reference_tag,
66
66
  )
67
+ from .network_preflight import NetworkPreflightResult, run_network_preflight
67
68
 
68
69
  anonymize_lib = importlib.import_module("rosotacom.anonymize")
69
70
 
@@ -1125,7 +1126,7 @@ def _get_local_ipv4s() -> list[str]:
1125
1126
  out = subprocess.check_output(["ip", "-o", "-4", "addr", "show"], text=True)
1126
1127
  except Exception:
1127
1128
  return []
1128
- return _IPV4_RE.findall(out)
1129
+ return re.findall(r"\binet\s+(\d+\.\d+\.\d+\.\d+)/", out)
1129
1130
 
1130
1131
 
1131
1132
  def _default_local_ip() -> str:
@@ -1234,6 +1235,29 @@ def _auto_identity(bindings: dict[str, PeerBinding]) -> str:
1234
1235
  raise RuntimeError(f"Auto identity ambiguous: matched peers={matches} for local IPv4s={sorted(local_ips)}.")
1235
1236
 
1236
1237
 
1238
+ def _network_preflight(
1239
+ bindings: dict[str, PeerBinding],
1240
+ identity: str,
1241
+ *,
1242
+ require_peer_reachable: bool = False,
1243
+ ) -> NetworkPreflightResult:
1244
+ return run_network_preflight(
1245
+ bindings,
1246
+ identity,
1247
+ local_addresses=_get_local_ipv4s(),
1248
+ require_peer_reachable=require_peer_reachable,
1249
+ )
1250
+
1251
+
1252
+ def _print_network_preflight(result: NetworkPreflightResult) -> None:
1253
+ reachability = "peer reachable" if result.peer_reachable else "route verified"
1254
+ print(
1255
+ f"Network preflight OK: {result.identity} ({result.local_address}) -> "
1256
+ f"{result.remote_identity} ({result.remote_address}) via {result.interface} "
1257
+ f"src {result.source_address}; {reachability}."
1258
+ )
1259
+
1260
+
1237
1261
  def _peer_com_name(peers: dict[str, Any], peer_key: str) -> str:
1238
1262
  value = (peers.get(peer_key) or {}).get("com-name")
1239
1263
  if value is None:
@@ -5123,10 +5147,18 @@ def start_session(args: argparse.Namespace) -> str:
5123
5147
  identity = args.identity or _auto_identity(bindings)
5124
5148
  if not args.identity:
5125
5149
  print(f"Auto-selected identity: {identity}")
5150
+ network_isolated = bool(getattr(args, "network_name", None))
5151
+ if not network_isolated:
5152
+ _print_network_preflight(
5153
+ _network_preflight(
5154
+ bindings,
5155
+ identity,
5156
+ require_peer_reachable=getattr(args, "require_peer_reachable", False),
5157
+ )
5158
+ )
5126
5159
  remote_name = _remote_peer_name(cfg, identity)
5127
5160
  instance = _resolve_session_instance(runtime, session, getattr(args, "instance_id", None))
5128
5161
  container_name = _container_name(remote_name, runtime, instance.instance_id)
5129
- network_isolated = bool(getattr(args, "network_name", None))
5130
5162
  if not network_isolated and not getattr(args, "skip_conflict_check", False):
5131
5163
  matching = _matching_com_containers(runtime, remote_name)
5132
5164
  if _fixed_com_container_name(runtime, remote_name) is not None:
@@ -5235,6 +5267,30 @@ def start_session(args: argparse.Namespace) -> str:
5235
5267
  return container_name
5236
5268
 
5237
5269
 
5270
+ def preflight_session(args: argparse.Namespace) -> NetworkPreflightResult:
5271
+ runtime = _load_runtime_config(args)
5272
+ session = _resolve_session(args.session_dir, runtime)
5273
+ cfg = _effective_session_config(session.host_dir, runtime)
5274
+ bindings = _resolve_bindings(
5275
+ cfg,
5276
+ runtime,
5277
+ peer=getattr(args, "peer", None),
5278
+ peer_address=getattr(args, "peer_address", None),
5279
+ )
5280
+ if not args.identity and not getattr(args, "auto_identity", True):
5281
+ raise RuntimeError("Missing --identity. Provide --identity <peer> or allow auto identity.")
5282
+ identity = args.identity or _auto_identity(bindings)
5283
+ if not args.identity:
5284
+ print(f"Auto-selected identity: {identity}")
5285
+ result = _network_preflight(
5286
+ bindings,
5287
+ identity,
5288
+ require_peer_reachable=getattr(args, "require_peer_reachable", False),
5289
+ )
5290
+ _print_network_preflight(result)
5291
+ return result
5292
+
5293
+
5238
5294
  def stop_session(args: argparse.Namespace) -> None:
5239
5295
  runtime = _load_runtime_config(args)
5240
5296
  session = _resolve_session(args.session_dir, runtime)
@@ -8668,6 +8724,11 @@ def _add_start_args(parser: argparse.ArgumentParser) -> None:
8668
8724
  parser.add_argument("--rewrite-formatting", action="store_true")
8669
8725
  _add_peer_arg(parser)
8670
8726
  _add_peer_address_arg(parser)
8727
+ parser.add_argument(
8728
+ "--require-peer-reachable",
8729
+ action="store_true",
8730
+ help="Require three bounded ICMP probes to the peer to succeed before starting.",
8731
+ )
8671
8732
  parser.add_argument("--instance-id", help="Join or create a named runtime session instance.")
8672
8733
  parser.add_argument(
8673
8734
  "--skip-conflict-check",
@@ -8681,6 +8742,22 @@ def _add_start_args(parser: argparse.ArgumentParser) -> None:
8681
8742
  parser.set_defaults(force=True, auto_identity=True)
8682
8743
 
8683
8744
 
8745
+ def _add_preflight_args(parser: argparse.ArgumentParser) -> None:
8746
+ _add_common_config_args(parser)
8747
+ _add_session_arg(parser, "session_dir_positional", nargs="?")
8748
+ _add_session_arg(parser, "-s", "--session-dir", dest="session_dir")
8749
+ _add_identity_arg(parser)
8750
+ parser.add_argument("--no-auto-identity", dest="auto_identity", action="store_false")
8751
+ _add_peer_arg(parser)
8752
+ _add_peer_address_arg(parser)
8753
+ parser.add_argument(
8754
+ "--require-peer-reachable",
8755
+ action="store_true",
8756
+ help="Require three bounded ICMP probes to the peer to succeed.",
8757
+ )
8758
+ parser.set_defaults(auto_identity=True)
8759
+
8760
+
8684
8761
  def _normalize_session_arg(args: argparse.Namespace) -> None:
8685
8762
  positional = getattr(args, "session_dir_positional", None)
8686
8763
  explicit = getattr(args, "session_dir", None)
@@ -8697,6 +8774,12 @@ def start_command(args: argparse.Namespace) -> int:
8697
8774
  return 0
8698
8775
 
8699
8776
 
8777
+ def preflight_command(args: argparse.Namespace) -> int:
8778
+ _normalize_session_arg(args)
8779
+ preflight_session(args)
8780
+ return 0
8781
+
8782
+
8700
8783
  def stop_command(args: argparse.Namespace) -> int:
8701
8784
  _normalize_session_arg(args)
8702
8785
  stop_session(args)
@@ -9052,6 +9135,7 @@ def videoquality_command(args: argparse.Namespace) -> int:
9052
9135
  # turn into `rosotacom start foo` — so a contract test keeps the two in sync.
9053
9136
  TOP_LEVEL_COMMANDS = {
9054
9137
  "start",
9138
+ "preflight",
9055
9139
  "stop",
9056
9140
  "doctor",
9057
9141
  "ps",
@@ -9127,6 +9211,13 @@ def main(argv: list[str] | None = None) -> int:
9127
9211
  _add_start_args(start_parser)
9128
9212
  start_parser.set_defaults(func=start_command)
9129
9213
 
9214
+ preflight_parser = subparsers.add_parser(
9215
+ "preflight",
9216
+ help="Validate the resolved local address, peer route, and optional peer reachability.",
9217
+ )
9218
+ _add_preflight_args(preflight_parser)
9219
+ preflight_parser.set_defaults(func=preflight_command)
9220
+
9130
9221
  stop_parser = subparsers.add_parser("stop", help="Stop rosotacom containers for a session.")
9131
9222
  _add_common_config_args(stop_parser)
9132
9223
  _add_session_arg(stop_parser, "session_dir_positional", nargs="?")
@@ -0,0 +1,134 @@
1
+ """Host data-plane validation for resolved communication peers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import ipaddress
6
+ import re
7
+ import subprocess
8
+ from collections.abc import Iterable
9
+ from dataclasses import dataclass
10
+
11
+ from .deployment import PeerBinding
12
+
13
+
14
+ @dataclass(frozen=True)
15
+ class NetworkPreflightResult:
16
+ identity: str
17
+ local_address: str
18
+ remote_identity: str
19
+ remote_address: str
20
+ interface: str
21
+ source_address: str
22
+ peer_reachable: bool | None
23
+
24
+
25
+ def _binding_ipv4(binding: PeerBinding, *, role: str) -> str:
26
+ try:
27
+ return str(ipaddress.IPv4Address(binding.address))
28
+ except ipaddress.AddressValueError as exc:
29
+ raise RuntimeError(
30
+ f"Network preflight failed: {role} peer {binding.peer!r} has non-IPv4 address "
31
+ f"{binding.address!r}. Communication peer addresses must be concrete IPv4 addresses."
32
+ ) from exc
33
+
34
+
35
+ def run_network_preflight(
36
+ bindings: dict[str, PeerBinding],
37
+ identity: str,
38
+ *,
39
+ local_addresses: Iterable[str],
40
+ require_peer_reachable: bool = False,
41
+ ) -> NetworkPreflightResult:
42
+ """Validate the selected local binding and kernel path to its peer."""
43
+ if identity not in bindings:
44
+ raise RuntimeError(f"Network preflight failed: identity {identity!r} is not one of peers={sorted(bindings)}.")
45
+ if len(bindings) != 2:
46
+ raise RuntimeError(f"Network preflight failed: expected exactly 2 peers, got peers={sorted(bindings)}.")
47
+
48
+ local = bindings[identity]
49
+ remote_identity = next(peer for peer in bindings if peer != identity)
50
+ remote = bindings[remote_identity]
51
+ local_address = _binding_ipv4(local, role="local")
52
+ remote_address = _binding_ipv4(remote, role="remote")
53
+
54
+ known_local_addresses = sorted(set(local_addresses))
55
+ if not known_local_addresses:
56
+ raise RuntimeError(
57
+ "Network preflight failed: could not determine any local IPv4 addresses. "
58
+ "Verify that the `ip` command is installed and the required data-plane interface is up."
59
+ )
60
+ if local_address not in known_local_addresses:
61
+ raise RuntimeError(
62
+ f"Network preflight failed for identity {identity!r}: configured local address {local_address} "
63
+ f"is not assigned to this host (local IPv4s: {known_local_addresses}). "
64
+ "Verify --identity and the peer bindings; if the address belongs to a VPN or other data-plane "
65
+ "interface, bring that interface up before starting communication."
66
+ )
67
+
68
+ try:
69
+ route = subprocess.run(
70
+ ["ip", "-4", "route", "get", remote_address],
71
+ text=True,
72
+ capture_output=True,
73
+ check=False,
74
+ )
75
+ except FileNotFoundError as exc:
76
+ raise RuntimeError("Network preflight failed: required command `ip` was not found.") from exc
77
+ route_output = (route.stdout or "").strip()
78
+ if route.returncode != 0 or not route_output:
79
+ detail = (route.stderr or route.stdout or "no route returned").strip()
80
+ raise RuntimeError(
81
+ f"Network preflight failed: no usable IPv4 route from {identity!r} ({local_address}) "
82
+ f"to {remote_identity!r} ({remote_address}): {detail}"
83
+ )
84
+ route_line = route_output.splitlines()[0]
85
+ source_match = re.search(r"\bsrc\s+(\d+\.\d+\.\d+\.\d+)\b", route_line)
86
+ interface_match = re.search(r"\bdev\s+(\S+)", route_line)
87
+ if source_match is None or interface_match is None:
88
+ raise RuntimeError(
89
+ f"Network preflight failed: could not determine source address and interface from route: {route_line}"
90
+ )
91
+ source_address = source_match.group(1)
92
+ interface = interface_match.group(1)
93
+ if source_address != local_address:
94
+ raise RuntimeError(
95
+ f"Network preflight failed: route to {remote_identity!r} ({remote_address}) uses source "
96
+ f"{source_address} on {interface}, but identity {identity!r} is bound to {local_address}. "
97
+ "Fix the route or peer binding before starting communication."
98
+ )
99
+
100
+ peer_reachable: bool | None = None
101
+ if require_peer_reachable:
102
+ try:
103
+ ping = subprocess.run(
104
+ ["ping", "-4", "-n", "-c", "3", "-W", "2", "-I", local_address, remote_address],
105
+ text=True,
106
+ capture_output=True,
107
+ check=False,
108
+ timeout=10,
109
+ )
110
+ except FileNotFoundError as exc:
111
+ raise RuntimeError(
112
+ "Network preflight failed: strict peer reachability was requested, but `ping` was not found."
113
+ ) from exc
114
+ except subprocess.TimeoutExpired as exc:
115
+ raise RuntimeError(
116
+ f"Network preflight failed: ICMP check from {local_address} to {remote_address} exceeded 10 seconds."
117
+ ) from exc
118
+ if ping.returncode != 0:
119
+ raise RuntimeError(
120
+ f"Network preflight failed: peer {remote_identity!r} ({remote_address}) did not answer "
121
+ f"3 ICMP echo requests sent from {local_address} via {interface}. This start requires an "
122
+ "already-reachable peer; verify the data plane, VPN, and firewall."
123
+ )
124
+ peer_reachable = True
125
+
126
+ return NetworkPreflightResult(
127
+ identity=identity,
128
+ local_address=local_address,
129
+ remote_identity=remote_identity,
130
+ remote_address=remote_address,
131
+ interface=interface,
132
+ source_address=source_address,
133
+ peer_reachable=peer_reachable,
134
+ )
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: rosotacom-dev
3
- Version: 2.5.dev52
3
+ Version: 2.5.dev53
4
4
  Summary: Host CLI for ROS OTA communication sessions.
5
5
  Author: develNor
6
6
  License-Expression: BSD-3-Clause
@@ -309,6 +309,24 @@ rosotacom start 1_heartbeat --identity b \
309
309
  --peer-address a=10.0.0.10 --peer-address b=10.0.0.11
310
310
  ```
311
311
 
312
+ Before `start` stops a conflicting container, writes an instance, or builds an
313
+ image, it verifies that the selected identity's address belongs to this host and
314
+ that the route to the other peer selects that address as its source. A missing
315
+ VPN/data-plane interface or a route through the wrong network therefore fails
316
+ without disturbing a working session. Run the same check on its own with
317
+ `preflight`; deployments that require the other host to be online already can
318
+ also make three bounded ICMP probes mandatory:
319
+
320
+ ```bash
321
+ rosotacom preflight 1_heartbeat --identity a \
322
+ --peer-address a=10.0.0.10 --peer-address b=10.0.0.11 \
323
+ --require-peer-reachable
324
+ ```
325
+
326
+ Peer reachability is opt-in because some valid networks block ICMP and peers may
327
+ be started in either order. `rosotacom start` accepts the same
328
+ `--require-peer-reachable` policy when a deployment does require it.
329
+
312
330
  `rosotacom` reads the static session input and creates generated files under `session-instances/<date>/<session>_<timestamp>_<id>/config/`, including per-peer plugin/session specs, topic lists, optional QoS, and optional `domain_bridge.yaml`. Catmux pane output is logged under the same instance in `logs/<peer>/catmux/`.
313
331
 
314
332
  What else a run leaves under `logs/<peer>/`, and which file to open first when a
@@ -6,7 +6,7 @@ rosotacom/benched_set.py,sha256=lBA1Rj04dbr2e-lIvYTWoL55_LdUpCAC-y7eIvJZ8lc,2721
6
6
  rosotacom/benchmark.py,sha256=3QoXJtWUGQPwvDWyQT_fOjx6K9G2pmvtnVurA21G2QY,76612
7
7
  rosotacom/benchmark_delta.py,sha256=qXzriBkbXOeJdlLKM4ziov1F-JfwlseYHE48S9hjFsQ,10005
8
8
  rosotacom/bundle_check.py,sha256=l_3BSuYk6juXQmsNU6jkxRbH8ayH2GkiW9ONKMQ1oJY,18270
9
- rosotacom/cli.py,sha256=P0nZ_VJFCDhaKA5-DsUJg63hnb12lVWwFwaTY2_6APY,409550
9
+ rosotacom/cli.py,sha256=DKG6cylS_37hFe5pvIQI1y-Iq-TxmwR2zIFjuUdB-zE,412811
10
10
  rosotacom/cli_benchmark.py,sha256=0R30if8b9F4uxTX53KmUuEJ0VXcK_5RcudqR9QARV5Q,355021
11
11
  rosotacom/deployment.py,sha256=XVhB8JjxhAsglSEEPtiVSeV6fCG0rgGmDRulh14oIYQ,7318
12
12
  rosotacom/ffmpeg_packet.py,sha256=zB1BHgY1kexr5sETVMkm4bkVnRytj9k2t0uOYiHUZ6M,4267
@@ -14,6 +14,7 @@ rosotacom/forensics.py,sha256=fxpjuRq3pA1wCf2ERai4SlVnNhJBqZbDh6ETRKODNd0,53576
14
14
  rosotacom/geomap.py,sha256=_85kWI-twzS3rf9KxamnFuhlw8Rp_fRk7n7xI-rUMFc,30292
15
15
  rosotacom/image_cache.py,sha256=71wC291j8rRMgF1D5pt2gTQdplmiIwnUDXk0BZoQNNQ,7185
16
16
  rosotacom/netem_dist.py,sha256=OWtc8asx-ow1wCxDoQuuJwJsFLl7F_lthLWsp-nlK-M,4642
17
+ rosotacom/network_preflight.py,sha256=mQsjVnHP3CPDfHyLsOdobtymcYhcvJbmJRbXm9EgGDs,5532
17
18
  rosotacom/network_profiles.py,sha256=GAyVL794zQ3HnGlr1d6GoG5_qDaF5BsXwN9WRGF7kLE,31530
18
19
  rosotacom/network_shaper.py,sha256=kjn9ZtiO1SMm8UHKIjmn92JBu_hE5TJF6vaNF1XKtNU,5463
19
20
  rosotacom/plots.py,sha256=YRGRxuGHSHDZmtL0sv1JS7cPVSL9VzjGYuvMxeHAj54,22674
@@ -197,9 +198,9 @@ rosotacom/resources/ws/session/creation/generate_session_files.py,sha256=NYdRSZT
197
198
  rosotacom/resources/ws/session/creation/run_session.py,sha256=G2hJhrmLp11gm3JZ6_C-zZcqu_reXlE_wmobB7BiCfU,16849
198
199
  rosotacom/resources/ws/session/creation/run_session_in_external_terminal.py,sha256=Ub_5f-ttUUc-rAPrZmg_Op34s8Z4C91hTyEn8fgyt-E,2633
199
200
  rosotacom/resources/ws/session/creation/strip_ansi.py,sha256=vNjpemcq4xHr0VG4x0bZqvgwEBEfOgN-b4PzbrM5nfM,851
200
- rosotacom_dev-2.5.dev52.dist-info/licenses/LICENSE.txt,sha256=FDB3-fao3lqZ94wsJHKAH7U88kAnfvZhZC8Hm5rN__g,1459
201
- rosotacom_dev-2.5.dev52.dist-info/METADATA,sha256=Rk127GLA4yN5kVy3gAYi6dxfO7VENDeGb43WZxadVrQ,54535
202
- rosotacom_dev-2.5.dev52.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
203
- rosotacom_dev-2.5.dev52.dist-info/entry_points.txt,sha256=im11ZWqBbgjQUBz2Ik2RNfMu54wlMwgiVpsBKeI7dCI,49
204
- rosotacom_dev-2.5.dev52.dist-info/top_level.txt,sha256=BZrtQcIVDWn_pQZR3Mlssqy5SaRXHjOa6B-1D9Fy4Ao,10
205
- rosotacom_dev-2.5.dev52.dist-info/RECORD,,
201
+ rosotacom_dev-2.5.dev53.dist-info/licenses/LICENSE.txt,sha256=FDB3-fao3lqZ94wsJHKAH7U88kAnfvZhZC8Hm5rN__g,1459
202
+ rosotacom_dev-2.5.dev53.dist-info/METADATA,sha256=ydDF-JJdTFmWNx_-sNEMeFbHmg2LwQ849jwxMMYRucM,55410
203
+ rosotacom_dev-2.5.dev53.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
204
+ rosotacom_dev-2.5.dev53.dist-info/entry_points.txt,sha256=im11ZWqBbgjQUBz2Ik2RNfMu54wlMwgiVpsBKeI7dCI,49
205
+ rosotacom_dev-2.5.dev53.dist-info/top_level.txt,sha256=BZrtQcIVDWn_pQZR3Mlssqy5SaRXHjOa6B-1D9Fy4Ao,10
206
+ rosotacom_dev-2.5.dev53.dist-info/RECORD,,