diff --git a/.agents/skills/configure-multi-robot/SKILL.md b/.agents/skills/configure-multi-robot/SKILL.md index a6ffacef1..bcddf7c7b 100644 --- a/.agents/skills/configure-multi-robot/SKILL.md +++ b/.agents/skills/configure-multi-robot/SKILL.md @@ -1,6 +1,6 @@ --- name: configure-multi-robot -description: Configure, name, and isolate multiple robots in AirStack. Use whenever launching multi-robot, multiple robots, swarm, or fleet scenarios; setting ROBOT_NAME; debugging cross-robot topic collisions; choosing a ROS_DOMAIN_ID; or namespacing topics, TF frames, and DDS bridges across robots. +description: Configure, name, and isolate multiple robots in AirStack — fleet files (config/fleets/, airstack up --fleet) first, legacy NUM_ROBOTS second. Use whenever launching multi-robot, multiple robots, swarm, or fleet scenarios; mixing different stacks/vehicles per robot (heterogeneous fleets, split placement via hosts:); setting ROBOT_NAME; debugging cross-robot topic collisions; choosing a ROS_DOMAIN_ID; or namespacing topics, TF frames, and DDS bridges across robots. license: Apache-2.0 metadata: author: AirLab CMU @@ -13,7 +13,8 @@ metadata: Reach for this skill any time you: -- Spawn more than one robot in simulation (`NUM_ROBOTS > 1`) +- Spawn more than one robot in simulation (`--fleet ` or legacy `NUM_ROBOTS > 1`) +- Need robots that differ (stack, vehicle, or offboard placement) — a **heterogeneous fleet** - Deploy multiple physical aircraft (VOXL, Jetson, etc.) - Debug topic collisions, missing topics on `/robot_2/...`, or "two robots talking on the same topic" - Write a new launch file or YAML config that hardcodes a topic path @@ -29,9 +30,51 @@ If you only ever touch one robot, you can usually skip this skill — but the mo - Basic understanding of ROS 2 namespaces and TF frame names - You have already read [`docs/robot/docker/robot_identity.md`](../../../docs/robot/docker/robot_identity.md), or are willing to as you go — that file is the canonical reference for the resolution mechanism -## How ROBOT_NAME Flows Through the Stack +## Fleet-First: Declare the Whole Deployment in One File -`ROBOT_NAME` is **not** a single static value. It is computed per container at shell start by `robot/docker/.bashrc` and propagated into every ROS launch substitution. The full chain: +Since RFC #380 P6, the preferred way to run multiple robots is a **fleet file** +(`config/fleets/*.yaml`): who exists, which vehicle each flies, which stack +each runs, and which ground host runs each split stack's offboard half. Full +guide: [`docs/development/fleets.md`](../../../docs/development/fleets.md). + +```bash +airstack fleet list # what exists + shape +airstack up --fleet sim_one_default --sim isaac # 1 robot, today's defaults +airstack up --fleet sim_three_mixed --sim isaac # heterogeneous: 3 robots, 3 stacks + a split +``` + +What `--fleet ` does: + +- validates the fleet (named errors), exports `FLEET_CONFIG_FILE` (container + path), and **derives `NUM_ROBOTS`** from the robot count (explicit env + `NUM_ROBOTS` still wins, with a banner) +- on Isaac, switches an untouched-default `ISAAC_SIM_SCRIPT_NAME` to the + generic fleet spawner `fleet_spawn.py` (spawns/scene/sensors from the fleet + + vehicle files) +- **homogeneous** fleets (same vehicle + stack everywhere) keep + `deploy.replicas`; each replica resolves its own entry via + `tools/fleet/resolve_fleet.py` in `.bashrc` (opt-in: only when + `FLEET_CONFIG_FILE` is set) +- **heterogeneous** fleets get generated per-robot services + (`airstack fleet generate ` → + `.airstack/generated/docker-compose.fleet.yaml`, auto-included; the `fleet` + compose profile replaces `desktop`) +- a robot with `hosts: {offboard: gcs}` on a split stack gets its `onboard` + entry point, and the named ground host gets a service running the same + stack with `AIRSTACK_STACK_ENTRY=offboard` — the declared successor of the + `desktop_split` / `offboard` profiles + +Test harness: `airstack test -m liveliness --fleet sim_three_mixed ...` +passes `FLEET_CONFIG_FILE` + the derived `NUM_ROBOTS`; without `--fleet`, +`--num-robots` behaves exactly as before. + +Everything below — the legacy `NUM_ROBOTS` + `robot_name_map` path — remains +the default without a fleet and is still fully supported; the topic/TF +namespacing rules and pitfalls apply identically under both paths. + +## How ROBOT_NAME Flows Through the Stack (Legacy Path) + +`ROBOT_NAME` is **not** a single static value. It is computed per container at shell start by `robot/docker/.bashrc` and propagated into every ROS launch substitution. When `FLEET_CONFIG_FILE` is set, a fleet branch in `.bashrc` resolves the whole fleet entry first (name, domain, stack placement, vehicle — pre-set env still wins per variable, and failures fall back to the legacy resolver below). The legacy chain: ``` .env (ROBOT_NAME_MAP_CONFIG_FILE, NUM_ROBOTS) @@ -137,9 +180,9 @@ does work because `docker exec -e` sets it in the process environment: docker exec -e ROBOT_NAME=robot_5 -e ROS_DOMAIN_ID=5 -it airstack-robot-desktop-1 bash ``` -## Launching Multiple Robots +## Launching Multiple Robots (Legacy `NUM_ROBOTS` Path) -AirStack launches multiple robots as **replicas of the same container**, not as multiple namespaces inside one container. Look at [`robot/docker/docker-compose.yaml`](../../../robot/docker/docker-compose.yaml): +Prefer `airstack up --fleet ` (above). Without a fleet, AirStack launches multiple robots as **replicas of the same container**, not as multiple namespaces inside one container — which is also why replicas can only ever be *identical* robots (heterogeneous fleets need the generated per-robot services). Look at [`robot/docker/docker-compose.yaml`](../../../robot/docker/docker-compose.yaml): ```yaml robot-desktop: @@ -160,7 +203,9 @@ docker ps --format '{{.Names}}' | grep robot-desktop The simulator side has to spawn matching vehicles — see [Sim-Side Robot Spawning](#sim-side-robot-spawning). -### `onboard_all` vs `onboard_local_offboard_global` +### `onboard_all` vs `onboard_local_offboard_global` (legacy roles) + +The stack-shaped successors: `--stack full_default` / `--stack lite_default` replace the role values, `--stack lite_offload_global:onboard|:offboard` replaces the split pair, and a fleet entry's `hosts: {offboard: }` replaces choosing the split *placement* by hand (see Fleet-First above). The role dispatch below still works without a stack/fleet. [`autonomy_bringup`](../../../robot/ros_ws/src/autonomy_bringup/) ships two layouts, selected by the `role` arg / `AUTONOMY_ROLE` env var: @@ -246,6 +291,8 @@ The `ms-airsim` container's `entrypoint.sh` (in `simulation/ms-airsim/docker/`) ### Isaac Sim (Pegasus) +With a fleet, [`fleet_spawn.py`](../../../simulation/isaac-sim/launch_scripts/fleet_spawn.py) is selected automatically: spawn positions come from each robot's `spawn:`, the scene from `sim.scene`, and sensor toggles from the vehicle manifests (any `lidar*` sensor enables the RTX lidar subgraph — the per-vehicle `ENABLE_LIDAR` equivalent). The legacy path: + [`simulation/isaac-sim/launch_scripts/example_multi_px4_pegasus_launch_script.py`](../../../simulation/isaac-sim/launch_scripts/example_multi_px4_pegasus_launch_script.py) reads `NUM_ROBOTS` and calls `spawn_drone(i)` in a loop. Each drone is created with `robot_name=f"robot_{index}"`, `vehicle_id=index`, `domain_id=index`, and an X offset for spacing: ```python @@ -280,8 +327,13 @@ CLI passthrough: ```bash airstack test -m takeoff_hover_land --sim msairsim --num-robots 1,3 -v +airstack test -m liveliness --sim isaacsim --fleet sim_three_mixed -v # fleet-first ``` +With `--fleet`, the fixture sets `FLEET_CONFIG_FILE`, derives `NUM_ROBOTS` +from the fleet, and pins `ISAAC_SIM_SCRIPT_NAME=fleet_spawn.py` on Isaac; +`env["fleet"]` carries the fleet name for tests that need it. + ## Common Pitfalls ### 1. Hardcoding the robot name in topics @@ -427,7 +479,10 @@ docker exec -e ROS_DOMAIN_ID=1 airstack-robot-desktop-1 bash -c \ ## References -- [`docs/robot/docker/robot_identity.md`](../../../docs/robot/docker/robot_identity.md) — canonical reference for the resolution mechanism +- [`docs/development/fleets.md`](../../../docs/development/fleets.md) — fleets: hierarchy, file tour, split placement, migration table (fleet-first path) +- [`config/fleets/`](../../../config/fleets/) — `sim_one_default.yaml` (parity with legacy), `sim_three_mixed.yaml` (heterogeneous + split) +- [`tools/fleet/resolve_fleet.py`](../../../tools/fleet/resolve_fleet.py) — fleet-entry resolver (`--table` to inspect, `--validate` to check) +- [`docs/robot/docker/robot_identity.md`](../../../docs/robot/docker/robot_identity.md) — canonical reference for the legacy resolution mechanism - [`docs/robot/autonomy_modes.md`](../../../docs/robot/autonomy_modes.md) — profile matrix (`desktop`, `desktop_split`, `voxl`, `l4t`, `offboard`) - [`robot/docker/robot_name_map/`](../../../robot/docker/robot_name_map/) — mapping YAMLs and `resolve_robot_name.py` - [`robot/ros_ws/src/autonomy_bringup/launch/robot.launch.xml`](../../../robot/ros_ws/src/autonomy_bringup/launch/robot.launch.xml) — top-level `push_ros_namespace` diff --git a/.airstack/modules/fleet.sh b/.airstack/modules/fleet.sh new file mode 100644 index 000000000..2ee97cd78 --- /dev/null +++ b/.airstack/modules/fleet.sh @@ -0,0 +1,160 @@ +#!/usr/bin/env bash + +# fleet.sh — `airstack fleet` command group (RFC #380 §2, Phase P6). +# +# Manages fleet files (config/fleets/*.yaml): who exists, which body (vehicle), +# which brain (stack), and which hosts run each split's offboard half. +# +# Subcommands: list | generate | help +# Resolution logic lives in tools/fleet/resolve_fleet.py; compose generation in +# tools/fleet/generate_fleet_compose.py. Guide: docs/development/fleets.md + +FLEETS_DIR="${PROJECT_ROOT}/config/fleets" +FLEET_RESOLVER="${PROJECT_ROOT}/tools/fleet/resolve_fleet.py" +FLEET_COMPOSE_GENERATOR="${PROJECT_ROOT}/tools/fleet/generate_fleet_compose.py" +FLEET_GENERATED_COMPOSE="${PROJECT_ROOT}/.airstack/generated/docker-compose.fleet.yaml" + +function _fleet_check_python { + if ! command -v python3 >/dev/null 2>&1; then + log_error "python3 is required for 'airstack fleet' commands." + return 1 + fi + if ! python3 -c 'import yaml' 2>/dev/null; then + log_error "PyYAML is required (pip3 install --user pyyaml)." + return 1 + fi +} + +# Resolve a fleet argument (name or path) to a host-side file path. +function _fleet_file_of { + local ref="$1" + if [[ "$ref" == *.yaml || "$ref" == */* ]]; then + [[ "$ref" != /* ]] && ref="$PROJECT_ROOT/$ref" + echo "$ref" + else + echo "$FLEETS_DIR/$ref.yaml" + fi +} + +# ── subcommands ────────────────────────────────────────────────────────────── + +function cmd_fleet_list { + _fleet_check_python || return 1 + if [ ! -d "$FLEETS_DIR" ]; then + log_error "No fleets directory at ${FLEETS_DIR}." + return 1 + fi + FLEETS_DIR="$FLEETS_DIR" PROJECT_ROOT_ENV="$PROJECT_ROOT" \ + FLEET_RESOLVER="$FLEET_RESOLVER" python3 - <<'PY' +import importlib.util, os, sys + +spec = importlib.util.spec_from_file_location("airstack_resolve_fleet", os.environ["FLEET_RESOLVER"]) +rf = importlib.util.module_from_spec(spec) +spec.loader.exec_module(rf) + +fleets_dir = os.environ["FLEETS_DIR"] +root = os.environ["PROJECT_ROOT_ENV"] + +rows = [] +for fname in sorted(os.listdir(fleets_dir)): + if not fname.endswith(".yaml"): + continue + name = fname[: -len(".yaml")] + path = os.path.join(fleets_dir, fname) + try: + fleet = rf.load_fleet(path) + errors = rf.validate_fleet(fleet, root) + if errors: + rows.append((name, "?", "?", "?", f"INVALID: {errors[0][:60]}")) + continue + resolved = rf.resolve_fleet(fleet, root) + robots = resolved["robots"] + stacks = sorted({r["stack_ref"] for r in robots}) + vehicles = sorted({r["vehicle"] for r in robots}) + split = any(r["hosts"] for r in robots) + homogeneous = rf.fleet_is_homogeneous(fleet, root) + shape = "homogeneous" if homogeneous else "heterogeneous" + if split: + shape += "+split" + rows.append((name, str(len(robots)), ",".join(vehicles), ",".join(stacks), shape)) + except rf.FleetError as exc: + rows.append((name, "?", "?", "?", f"INVALID: {str(exc)[:60]}")) + +if not rows: + print("No fleet files under config/fleets/. See docs/development/fleets.md.") + raise SystemExit(0) + +headers = ("FLEET", "ROBOTS", "VEHICLES", "STACKS", "SHAPE") +widths = [max(len(str(r[i])) for r in rows + [headers]) for i in range(5)] +fmt = " ".join("{:<%d}" % w for w in widths) +print(fmt.format(*headers)) +for row in rows: + print(fmt.format(*row)) +PY +} + +function cmd_fleet_generate { + _fleet_check_python || return 1 + local ref="${1:-}" + if [ -z "$ref" ] || [ $# -gt 1 ]; then + log_error "Usage: airstack fleet generate " + log_error " e.g. airstack fleet generate sim_three_mixed" + return 1 + fi + local fleet_file + fleet_file="$(_fleet_file_of "$ref")" + if [ ! -f "$fleet_file" ]; then + log_error "Fleet not found: ${fleet_file}" + cmd_fleet_list + return 1 + fi + python3 "$FLEET_COMPOSE_GENERATOR" "$fleet_file" --project-root "$PROJECT_ROOT" || return 1 + _fleet_generate_bridge_routers "$fleet_file" +} + +# Split stacks referenced by the fleet need their bridge-derived DDS-router +# configs materialized before launch (the onboard entry loads +# .airstack/generated/dds_router..yaml and fails fast without it). +function _fleet_generate_bridge_routers { + local fleet_file="$1" bridge stack_dir stack_name + while IFS= read -r stack_dir; do + bridge="$PROJECT_ROOT/$stack_dir/bridge.yaml" + [ -f "$bridge" ] || continue + stack_name="$(basename "$stack_dir")" + log_info "Generating DDS-router config for split stack '${stack_name}' (bridge.yaml)..." + python3 "$PROJECT_ROOT/tools/gen_dds_router.py" "$bridge" || return 1 + done < <(FLEET_FILE="$fleet_file" python3 - <<'PY' +import os, yaml +with open(os.environ["FLEET_FILE"], encoding="utf-8") as f: + doc = yaml.safe_load(f) or {} +stacks = set() +default = ((doc.get("defaults") or {}).get("stack")) +for robot in (doc.get("robots") or {}).values(): + stacks.add((robot or {}).get("stack") or default) +for s in sorted(s for s in stacks if s): + print(s) +PY +) +} + +# Dispatcher for the `fleet` command group. +function cmd_fleet_dispatch { + local sub="${1:-help}" + if [ $# -gt 0 ]; then shift; fi + case "$sub" in + list) cmd_fleet_list "$@" ;; + generate) cmd_fleet_generate "$@" ;; + help|-h|--help) print_command_help fleet ;; + *) + log_error "Unknown fleet subcommand: '$sub'" + print_command_help fleet + return 1 + ;; + esac +} + +# Register commands from this module. +function register_fleet_commands { + COMMANDS["fleet"]="cmd_fleet_dispatch" + COMMAND_HELP["fleet"]="Manage fleet files: list|generate (RFC #380 §2; see 'airstack help fleet')" +} diff --git a/.airstack/modules/ready.sh b/.airstack/modules/ready.sh index 12ceb2897..b18c6eaf1 100644 --- a/.airstack/modules/ready.sh +++ b/.airstack/modules/ready.sh @@ -47,9 +47,12 @@ function _ready_ros2_exec { timeout $timeout_s $cmd" 2>/dev/null } -# List running robot containers (compose replicas), one per line. +# List running robot containers, one per line: compose replicas +# (airstack-robot-desktop-N) AND fleet-generated per-robot services +# (airstack-robot_N-1). Ground hosts (gcs-robot_N tenants) are NOT robots — +# they never run MAVROS/PX4 — so exclude them. function _ready_robot_containers { - docker ps --format '{{.Names}}' | grep -E -- '-robot-' | sort + docker ps --format '{{.Names}}' | grep -E -- '-robot[-_]' | grep -v 'gcs-' | sort } # domain for robot container (via the same .bashrc resolution airstack status uses) diff --git a/.airstack/modules/sync.sh b/.airstack/modules/sync.sh new file mode 100644 index 000000000..703be7c8d --- /dev/null +++ b/.airstack/modules/sync.sh @@ -0,0 +1,291 @@ +#!/usr/bin/env bash + +# sync.sh — `airstack sync` (RFC #380 §3, Phase P6). +# +# The checkout-level sync driven by ./airstack.yaml (the one hand-edited entry +# point). In order: +# 1. read airstack.yaml (absent file = plain module sync, nothing more) +# 2. upsert its `modules:` additions into modules.repos (naming every +# deviation; bare {version:} without repo: is a named error — registry +# resolution is future work) +# 3. run the module sync (modules.repos → modules/ → overlay → layer plan) +# 4. fetch declared external stack repos into gitignored stacks/.external/ +# / (same vcs machinery as the module sync; pinned refs only) +# 5. validate the declared fleet file (tools/fleet/resolve_fleet.py) +# 6. write .airstack/generated/effective_sources.yaml — what this sync +# actually resolved +# +# Deliberately NOT done (documented future work): rewriting .env (it stays +# hand-edited; airstack.yaml layers on top) and release-set pin resolution. +# +# Loads after module.sh (alphabetical module load order), so the module +# command group's helpers are available. + +AIRSTACK_YAML_FILE="${PROJECT_ROOT}/airstack.yaml" +EXTERNAL_STACKS_DIR="${PROJECT_ROOT}/stacks/.external" +EFFECTIVE_SOURCES_FILE="${PROJECT_ROOT}/.airstack/generated/effective_sources.yaml" +FLEET_RESOLVER_TOOL="${PROJECT_ROOT}/tools/fleet/resolve_fleet.py" + +function _sync_check_python { + if ! command -v python3 >/dev/null 2>&1; then + log_error "python3 is required for 'airstack sync'." + return 1 + fi + if ! python3 -c 'import yaml' 2>/dev/null; then + log_error "PyYAML is required (pip3 install --user pyyaml)." + return 1 + fi +} + +# Read a scalar key from airstack.yaml (empty when absent). +function _sync_yaml_scalar { + AIRSTACK_YAML_FILE="$AIRSTACK_YAML_FILE" SYNC_KEY="$1" python3 - <<'PY' +import os, yaml +path = os.environ["AIRSTACK_YAML_FILE"] +with open(path, encoding="utf-8") as f: + data = yaml.safe_load(f) or {} +value = data.get(os.environ["SYNC_KEY"]) +print(value if isinstance(value, (str, int, float)) else "") +PY +} + +# Emit tab-separated rows for a mapping key: namefield1field2. +# modules: name kind(path|repo|version-only) value version +# stacks: alias repo ref +function _sync_module_rows { + AIRSTACK_YAML_FILE="$AIRSTACK_YAML_FILE" python3 - <<'PY' +import os, sys, yaml +with open(os.environ["AIRSTACK_YAML_FILE"], encoding="utf-8") as f: + data = yaml.safe_load(f) or {} +for name, entry in (data.get("modules") or {}).items(): + entry = entry or {} + if not isinstance(entry, dict): + print(f"{name}\tinvalid\t\t") + continue + if entry.get("path"): + print(f"{name}\tpath\t{entry['path']}\t") + elif entry.get("repo"): + print(f"{name}\trepo\t{entry['repo']}\t{entry.get('version', '')}") + else: + print(f"{name}\tversion-only\t\t{entry.get('version', '')}") +PY +} + +function _sync_stack_rows { + AIRSTACK_YAML_FILE="$AIRSTACK_YAML_FILE" python3 - <<'PY' +import os, yaml +with open(os.environ["AIRSTACK_YAML_FILE"], encoding="utf-8") as f: + data = yaml.safe_load(f) or {} +for alias, entry in (data.get("stacks") or {}).items(): + entry = entry or {} + print(f"{alias}\t{entry.get('repo', '')}\t{entry.get('ref', '')}") +PY +} + +function cmd_sync { + _sync_check_python || return 1 + if [ $# -gt 0 ]; then + log_error "Usage: airstack sync (no arguments; configuration lives in airstack.yaml)" + return 1 + fi + + local have_yaml=false + if [ -f "$AIRSTACK_YAML_FILE" ]; then + have_yaml=true + else + log_warn "No airstack.yaml at ${AIRSTACK_YAML_FILE} — running plain module sync only." + fi + + local errors=0 + + # ── 2. modules: additions → modules.repos (named deviations) ──────────── + if [ "$have_yaml" = true ]; then + local name kind value version + while IFS=$'\t' read -r name kind value version; do + [ -n "$name" ] || continue + case "$kind" in + path) + local mod_path="$value" + [[ "$mod_path" != /* ]] && mod_path="$PROJECT_ROOT/$mod_path" + if [ ! -d "$mod_path" ]; then + log_error "airstack.yaml modules.${name}: path '$value' does not exist" + errors=1; continue + fi + log_info "airstack.yaml override: module '${name}' from local path ${value}" + MODULE_ENTRY_NAME="$name" MODULE_ENTRY_KIND="local" \ + MODULE_ENTRY_PATH="$value" _module_repos_upsert || errors=1 + ;; + repo) + if [ -z "$version" ]; then + log_error "airstack.yaml modules.${name}: repo entries must pin a version: (tag or SHA)" + errors=1; continue + fi + local ref + for ref in $MODULE_BRANCHLIKE_REFS; do + if [ "$version" = "$ref" ]; then + log_error "airstack.yaml modules.${name}: '${version}' looks like a branch — pin a tag or SHA (RFC #379 §3)" + errors=1; continue 2 + fi + done + log_info "airstack.yaml override: module '${name}' from ${value} @ ${version}" + MODULE_ENTRY_NAME="$name" MODULE_ENTRY_KIND="git" \ + MODULE_ENTRY_URL="$value" MODULE_ENTRY_VERSION="$version" \ + _module_repos_upsert || errors=1 + ;; + version-only) + log_error "airstack.yaml modules.${name}: {version: ${version:-?}} without repo: needs registry resolution — not implemented yet (RFC #379 §7). Give repo: or path:." + errors=1 + ;; + *) + log_error "airstack.yaml modules.${name}: entry must be a mapping with path: or repo:+version:" + errors=1 + ;; + esac + done < <(_sync_module_rows) + fi + + # ── 3. module sync (modules.repos → checkouts → overlay → layer plan) ─── + if declare -f cmd_module_sync >/dev/null; then + cmd_module_sync || errors=1 + else + log_warn "module command group not loaded — skipping module sync." + fi + + # ── 4. external stack repos → stacks/.external// ───────────────── + local stack_count=0 + if [ "$have_yaml" = true ]; then + local alias repo sref + while IFS=$'\t' read -r alias repo sref; do + [ -n "$alias" ] || continue + if [ -z "$repo" ] || [ -z "$sref" ]; then + log_error "airstack.yaml stacks.${alias}: needs repo: and a pinned ref:" + errors=1; continue + fi + local bref + for bref in $MODULE_BRANCHLIKE_REFS; do + if [ "$sref" = "$bref" ]; then + log_error "airstack.yaml stacks.${alias}: ref '${sref}' looks like a branch — pin a tag or SHA (RFC #380 §3)" + errors=1; continue 2 + fi + done + _module_ensure_vcs || { errors=1; continue; } + mkdir -p "$EXTERNAL_STACKS_DIR" + # Same self-heal as the module sync: a checkout without .git makes + # vcs import refuse; clear it for a clean reclone. + if [ -d "$EXTERNAL_STACKS_DIR/$alias" ] && [ ! -e "$EXTERNAL_STACKS_DIR/$alias/.git" ]; then + log_warn "stacks/.external/${alias} exists without .git — clearing for reclone" + rm -rf "${EXTERNAL_STACKS_DIR:?}/$alias" + fi + local tmp_repos + tmp_repos="$(mktemp)" + SYNC_ALIAS="$alias" SYNC_REPO="$repo" SYNC_REF="$sref" \ + TMP_REPOS="$tmp_repos" python3 - <<'PY' +import os, yaml +with open(os.environ["TMP_REPOS"], "w", encoding="utf-8") as f: + yaml.safe_dump({"repositories": {os.environ["SYNC_ALIAS"]: { + "type": "git", + "url": os.environ["SYNC_REPO"], + "version": os.environ["SYNC_REF"], + }}}, f) +PY + log_info "Fetching external stack repo '${alias}' (${repo} @ ${sref}) into stacks/.external/..." + if ! vcs import "$EXTERNAL_STACKS_DIR" --input "$tmp_repos" --recursive; then + log_error "vcs import failed for external stack repo '${alias}'." + errors=1 + else + stack_count=$((stack_count + 1)) + fi + rm -f "$tmp_repos" + done < <(_sync_stack_rows) + fi + + # ── 5. validate the declared fleet ─────────────────────────────────────── + local fleet_file="" + if [ "$have_yaml" = true ]; then + fleet_file="$(_sync_yaml_scalar fleet)" + if [ -n "$fleet_file" ]; then + local fleet_host="$fleet_file" + [[ "$fleet_host" != /* ]] && fleet_host="$PROJECT_ROOT/$fleet_file" + if python3 "$FLEET_RESOLVER_TOOL" "$fleet_host" --project-root "$PROJECT_ROOT" --validate; then + log_info "Fleet OK: ${fleet_file}" + else + log_error "Declared fleet failed validation: ${fleet_file} (errors above)" + errors=1 + fi + fi + local sim_sel + sim_sel="$(_sync_yaml_scalar sim)" + case "$sim_sel" in + ""|isaacsim|msairsim|none) ;; + *) log_error "airstack.yaml sim: '${sim_sel}' (expected isaacsim | msairsim | none)"; errors=1;; + esac + fi + + # ── 6. record what this sync resolved ─────────────────────────────────── + mkdir -p "$(dirname "$EFFECTIVE_SOURCES_FILE")" + AIRSTACK_YAML_FILE="$AIRSTACK_YAML_FILE" \ + PROJECT_ROOT_ENV="$PROJECT_ROOT" \ + EFFECTIVE_SOURCES_FILE="$EFFECTIVE_SOURCES_FILE" \ + SYNC_HAVE_YAML="$have_yaml" python3 - <<'PY' +import os, yaml + +root = os.environ["PROJECT_ROOT_ENV"] +out_path = os.environ["EFFECTIVE_SOURCES_FILE"] + +top = {} +if os.environ["SYNC_HAVE_YAML"] == "true": + with open(os.environ["AIRSTACK_YAML_FILE"], encoding="utf-8") as f: + top = yaml.safe_load(f) or {} + +modules = {} +repos_path = os.path.join(root, "modules.repos") +if os.path.exists(repos_path): + with open(repos_path, encoding="utf-8") as f: + repos = yaml.safe_load(f) or {} + for name, entry in (repos.get("repositories") or {}).items(): + modules[name] = {"source": entry.get("url"), "pin": entry.get("version")} + for entry in repos.get("x-local-modules") or []: + modules[entry.get("name")] = {"source": entry.get("path"), "pin": "local"} + +stacks = {} +for alias, entry in (top.get("stacks") or {}).items(): + entry = entry or {} + stacks[alias] = { + "repo": entry.get("repo"), + "ref": entry.get("ref"), + "path": f"stacks/.external/{alias}", + } + +record = { + "release": top.get("release"), + "fleet": top.get("fleet"), + "sim": top.get("sim"), + "modules": modules, + "external_stacks": stacks, +} +header = ( + "# GENERATED by `airstack sync` — what this checkout's sources resolved to\n" + "# (airstack.yaml -> modules.repos / stacks/.external / fleet). DO NOT EDIT.\n" +) +with open(out_path, "w", encoding="utf-8") as f: + f.write(header) + yaml.safe_dump(record, f, sort_keys=True, default_flow_style=False) +print(f"Wrote {out_path}") +PY + + if [ "$errors" -ne 0 ]; then + log_error "airstack sync finished with errors (named above)." + return 1 + fi + log_info "Sync complete." + if [ "$stack_count" -gt 0 ]; then + log_info "External stacks are addressable in fleets as / (stacks/.external/)." + fi + return 0 +} + +# Register commands from this module. +function register_sync_commands { + COMMANDS["sync"]="cmd_sync" + COMMAND_HELP["sync"]="Sync the checkout from airstack.yaml: modules, external stack repos, fleet validation (RFC #380 §3)" +} diff --git a/.env b/.env index 45b3cac58..12399f94b 100644 --- a/.env +++ b/.env @@ -12,7 +12,7 @@ PROJECT_NAME="airstack" # If you've run ./airstack.sh setup, then this will auto-generate from the git commit hash every time a change is made # to a Dockerfile or docker-compose.yaml file. Otherwise this can also be set explicitly to make a release version. # auto-generated from git commit hash -VERSION="0.20.0-alpha.5" +VERSION="0.20.0-alpha.6" # Choose "dev" or "prebuilt". "dev" is for mounted code that must be built live. "prebuilt" is for built ros_ws baked into the image DOCKER_IMAGE_BUILD_MODE="dev" # Where to push and pull images from. Can replace with your docker hub username if using docker hub. diff --git a/.gitignore b/.gitignore index 60a93e4d5..34ac08ada 100644 --- a/.gitignore +++ b/.gitignore @@ -123,3 +123,8 @@ robot/ros_ws/src/modules/ simulation/isaac-sim/launch_scripts/modules/ stacks/.external/ /.airstack/generated/ + +# Machine-local config overlay (RFC #380 §1/§3): per-serial calibration and +# local overrides — never shared through trunk. The README stays committed. +/config/local/* +!/config/local/README.md diff --git a/AGENTS.md b/AGENTS.md index 3e51c8424..744f0e3b0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -97,7 +97,7 @@ For detailed step-by-step instructions, refer to the **`.agents/skills/`** direc | [run-system-tests](.agents/skills/run-system-tests) | Running the pytest system test harness (marks, MetricsRecorder, /pytest PR trigger) | | [add-behavior-tree-node](.agents/skills/add-behavior-tree-node) | Creating behavior tree nodes | | [use-airstack-cli](.agents/skills/use-airstack-cli) | Using the `airstack` CLI and the non-interactive `docker exec` pattern | -| [configure-multi-robot](.agents/skills/configure-multi-robot) | Setting up multiple robots, ROBOT_NAME namespacing, and ROS_DOMAIN_ID isolation | +| [configure-multi-robot](.agents/skills/configure-multi-robot) | Setting up multiple robots — fleet files (`--fleet`, heterogeneous + split placement) and legacy NUM_ROBOTS, ROBOT_NAME namespacing, ROS_DOMAIN_ID isolation | | [bump-version-and-release](.agents/skills/bump-version-and-release) | Bumping `.env` VERSION and CHANGELOG before merge to clear the version-check gate | | [capture-discovered-knowledge](.agents/skills/capture-discovered-knowledge) | After long context-discovery / surprising findings, persist to AGENTS.md or a new skill so the next agent doesn't redo the work | | [use-feature-notebook](.agents/skills/use-feature-notebook) | At the start of EVERY feature implementation: create `notebook/NNN-feature-slug/design_spec.md`, store test artifacts under `results/`, write `results/results_summary.md`, and populate the PR from it | @@ -164,6 +164,9 @@ airstack install # Install Docker and dependencies # Container management airstack up [service] # Start services (robot, isaac-sim, gcs) airstack up --sim isaac|airsim --robots N # Intent flags: derive profiles/URDF/sim script (add --headless, --play/--no-play, --no-autolaunch, --wait, --dry-run) +airstack up --fleet --sim isaac # Fleet launch (RFC #380): config/fleets/.yaml drives identity/placement/spawns; see docs/development/fleets.md +airstack fleet list|generate # Fleet files: table / per-robot compose for heterogeneous fleets +airstack sync # Sync from airstack.yaml: modules, external stack repos, fleet validation airstack ready # Wait until the stack is flight-ready (containers → sim /clock → nodes → PX4); --json for scripts airstack down [service] # Stop services airstack status # Show container status @@ -389,7 +392,9 @@ Each major component has its own Docker container: ## Multi-Robot Configuration -Multi-robot is implemented via Docker Compose **replicas**, not multiple namespaces in one container. Setting `NUM_ROBOTS=3` in [`.env`](.env) spawns three separate containers (`airstack-robot-desktop-1`, `-2`, `-3`) via `deploy.replicas: ${NUM_ROBOTS:-1}` in [`robot/docker/docker-compose.yaml`](robot/docker/docker-compose.yaml). +**Fleet-first (RFC #380, opt-in):** a fleet file under [`config/fleets/`](config/fleets/) declares who exists, which vehicle ([`config/vehicles/`](config/vehicles/)), which stack, and which ground hosts run split-stack offboard halves. `airstack up --fleet ` validates it, derives `NUM_ROBOTS`, selects the generic Isaac fleet spawner ([`fleet_spawn.py`](simulation/isaac-sim/launch_scripts/fleet_spawn.py)), and — for heterogeneous fleets — includes generated per-robot compose services (`airstack fleet generate`). Containers resolve their whole fleet entry via [`tools/fleet/resolve_fleet.py`](tools/fleet/resolve_fleet.py) when `FLEET_CONFIG_FILE` is set. Guide: [docs/development/fleets.md](docs/development/fleets.md). Without a fleet, everything below is unchanged (the default). + +Legacy multi-robot is implemented via Docker Compose **replicas**, not multiple namespaces in one container. Setting `NUM_ROBOTS=3` in [`.env`](.env) spawns three separate containers (`airstack-robot-desktop-1`, `-2`, `-3`) via `deploy.replicas: ${NUM_ROBOTS:-1}` in [`robot/docker/docker-compose.yaml`](robot/docker/docker-compose.yaml). `ROBOT_NAME` is **not** set directly in `.env`. Each container computes it at startup: [`robot/docker/.bashrc`](robot/docker/.bashrc) reads `ROBOT_NAME_SOURCE` (`container_name` or `hostname`) and runs [`resolve_robot_name.py`](robot/docker/robot_name_map/resolve_robot_name.py) against the mapping in [`robot/docker/robot_name_map/`](robot/docker/robot_name_map/) (default: [`default_robot_name_map.yaml`](robot/docker/robot_name_map/default_robot_name_map.yaml)). The resolver exports both `ROBOT_NAME` and `ROS_DOMAIN_ID` — robot N gets domain N by default, so each robot is on its own DDS partition. diff --git a/airstack.sh b/airstack.sh index 9cc34cf7b..f1344d84e 100755 --- a/airstack.sh +++ b/airstack.sh @@ -163,6 +163,42 @@ function print_command_help { echo " --stack NAME Launch a stack folder (stacks/NAME/launch/stack.launch.xml) instead of" echo " the legacy AUTONOMY_ROLE dispatch. NAME:ENTRY selects an alternate entry" echo " file (launch/ENTRY.launch.xml). See docs/development/stacks.md." + echo " --fleet NAME Launch a fleet (config/fleets/NAME.yaml): exports FLEET_CONFIG_FILE," + echo " derives NUM_ROBOTS, selects the Isaac fleet spawner, and (for" + echo " heterogeneous fleets) includes the generated per-robot services." + echo " See docs/development/fleets.md." + ;; + fleet) + echo "Usage: airstack fleet " + echo "" + echo "Manage fleet files (RFC #380 §2): config/fleets/*.yaml declare who" + echo "exists, which vehicle (body), which stack (brain), and which ground" + echo "hosts run each split stack's offboard half." + echo "Guide: docs/development/fleets.md" + echo "" + echo "Subcommands:" + echo " list Table of fleets: robots, vehicles, stacks, shape" + echo " (homogeneous / heterogeneous / +split)." + echo " generate Write .airstack/generated/docker-compose.fleet.yaml:" + echo " one self-contained service per robot plus one per" + echo " (ground host x offboard tenant). Homogeneous fleets" + echo " need no generation (deploy.replicas handles them) —" + echo " the command says so and writes nothing." + echo "" + echo "Run a fleet: airstack up --fleet [--sim isaac|airsim]" + ;; + sync) + echo "Usage: airstack sync" + echo "" + echo "Sync the checkout from airstack.yaml (RFC #380 §3): upsert its" + echo "modules: additions into modules.repos (naming every deviation), run" + echo "the module sync, fetch declared external stack repos into gitignored" + echo "stacks/.external// (fleets address them as /)," + echo "validate the declared fleet, and record the resolved sources in" + echo ".airstack/generated/effective_sources.yaml." + echo "" + echo "NOT done (future work): rewriting .env (it stays hand-edited) and" + echo "release-set pin resolution against a registry (RFC #379 §7)." ;; images) echo "Usage: airstack images" @@ -1006,6 +1042,8 @@ function parse_launch_intent { AIRSTACK_INTENT_PLAY="" AIRSTACK_INTENT_AUTOLAUNCH="" AIRSTACK_INTENT_STACK="" + AIRSTACK_INTENT_FLEET="" + AIRSTACK_FLEET_COMPOSE_FILE="" AIRSTACK_DRY_RUN="" AIRSTACK_UP_WAIT="" @@ -1023,6 +1061,8 @@ function parse_launch_intent { --no-autolaunch) AIRSTACK_INTENT_AUTOLAUNCH="false";; --stack) i=$((i+1)); AIRSTACK_INTENT_STACK="${args[$i]:-}";; --stack=*) AIRSTACK_INTENT_STACK="${a#--stack=}";; + --fleet) i=$((i+1)); AIRSTACK_INTENT_FLEET="${args[$i]:-}";; + --fleet=*) AIRSTACK_INTENT_FLEET="${a#--fleet=}";; --wait) AIRSTACK_UP_WAIT="1";; # NOTE: shadows compose's own `up --dry-run`; ours validates the # derived launch config and exits without starting services. @@ -1040,6 +1080,10 @@ function parse_launch_intent { ""|isaac|isaacsim|airsim|msairsim|ms-airsim) ;; *) log_error "Unknown --sim '$AIRSTACK_INTENT_SIM' (expected: isaac | airsim)"; return 1;; esac + if [[ -n "$AIRSTACK_INTENT_FLEET" && -n "$AIRSTACK_INTENT_ROBOTS" ]]; then + log_error "--fleet and --robots are mutually exclusive: the fleet file defines the robot count (RFC #380 §2)." + return 1 + fi return 0 } @@ -1074,6 +1118,106 @@ function apply_stack_intent { return 0 } +# Fleet dispatch (RFC #380 §2): validate the fleet file, export +# FLEET_CONFIG_FILE (the CONTAINER path — config/ is bind-mounted at +# /root/AirStack/config) plus the derived NUM_ROBOTS, switch an +# untouched-default Isaac script to the generic fleet spawner, and — for +# HETEROGENEOUS fleets — regenerate the per-robot compose services and swap +# the desktop profile for the generated services' `fleet` profile (so +# deploy.replicas doesn't double-spawn identical containers next to them). +# +# Everything is leaf-value precedence: explicit OS-env NUM_ROBOTS / +# ISAAC_SIM_SCRIPT_NAME still win, with an override banner. +# +# Arg 1: fleet selector — a name under config/fleets/, a host path, or the +# container path form (as the test harness passes via FLEET_CONFIG_FILE). +# Remaining args: passed through to resolve_launch_var (--env-file scanning). +function apply_fleet_intent { + local fleet_ref="$1"; shift + fleet_ref="${fleet_ref#/root/AirStack/}" # container path → checkout-relative + local fleet_host + if [[ "$fleet_ref" == *.yaml || "$fleet_ref" == */* ]]; then + fleet_host="$fleet_ref" + [[ "$fleet_host" != /* ]] && fleet_host="$PROJECT_ROOT/$fleet_ref" + else + fleet_host="$PROJECT_ROOT/config/fleets/$fleet_ref.yaml" + fi + if [[ ! -f "$fleet_host" ]]; then + local available + available=$(ls -1 "$PROJECT_ROOT/config/fleets" 2>/dev/null | sed 's/\.yaml$//' | tr '\n' ' ') + log_error "Unknown fleet '$fleet_ref' — $fleet_host does not exist. Available fleets: ${available:-}" + return 1 + fi + local fleet_name + fleet_name="$(basename "$fleet_host" .yaml)" + + if ! python3 "$PROJECT_ROOT/tools/fleet/resolve_fleet.py" "$fleet_host" \ + --project-root "$PROJECT_ROOT" --validate >/dev/null; then + log_error "Fleet '$fleet_name' failed validation (named errors above)." + return 1 + fi + + local fleet_rel="${fleet_host#$PROJECT_ROOT/}" + export FLEET_CONFIG_FILE="/root/AirStack/$fleet_rel" + + # NUM_ROBOTS is implicit in the fleet (RFC #380 §3 migration table). + local fleet_robots + fleet_robots=$(FLEET_HOST="$fleet_host" python3 -c ' +import os, yaml +with open(os.environ["FLEET_HOST"], encoding="utf-8") as f: + print(len((yaml.safe_load(f) or {}).get("robots") or {}))') + if [[ -n "${NUM_ROBOTS:-}" && "$NUM_ROBOTS" != "$fleet_robots" ]]; then + log_warn "OVERRIDE: explicit NUM_ROBOTS=$NUM_ROBOTS wins over fleet '$fleet_name' ($fleet_robots robot(s)) — containers and sim spawns will disagree with the fleet file." + else + export NUM_ROBOTS="$fleet_robots" + fi + + # Isaac: the fleet spawner reads spawns/vehicles/scene from the fleet file, + # replacing the hardcoded one-/multi-drone example scripts. Explicit OS-env + # ISAAC_SIM_SCRIPT_NAME still wins; the stock defaults get switched. + local profiles script + profiles=$(resolve_launch_var COMPOSE_PROFILES "$@") + if [[ ",$profiles," == *",isaac-sim,"* ]]; then + if [[ -n "${ISAAC_SIM_SCRIPT_NAME:-}" ]]; then + [[ "$ISAAC_SIM_SCRIPT_NAME" != "fleet_spawn.py" ]] && \ + log_warn "OVERRIDE: explicit ISAAC_SIM_SCRIPT_NAME='$ISAAC_SIM_SCRIPT_NAME' wins over the fleet spawner — make sure it spawns fleet '$fleet_name' (reads FLEET_CONFIG_FILE)." + else + script=$(resolve_launch_var ISAAC_SIM_SCRIPT_NAME "$@") + case "$script" in + example_one_px4_pegasus_launch_script.py|example_multi_px4_pegasus_launch_script.py|"") + log_info "--fleet $fleet_name → ISAAC_SIM_SCRIPT_NAME=fleet_spawn.py (was ${script:-})" + export ISAAC_SIM_SCRIPT_NAME="fleet_spawn.py";; + fleet_spawn.py) ;; + *) + log_warn "Custom ISAAC_SIM_SCRIPT_NAME='$script' with a fleet: make sure it spawns fleet '$fleet_name' (reads FLEET_CONFIG_FILE).";; + esac + fi + fi + + # Heterogeneous fleet → deploy.replicas cannot stamp it: regenerate the + # per-robot services and include them (cmd_up adds the -f); homogeneous + # fleets keep replicas untouched. + AIRSTACK_FLEET_COMPOSE_FILE="" + local shape + shape=$(python3 "$PROJECT_ROOT/tools/fleet/generate_fleet_compose.py" \ + "$fleet_host" --project-root "$PROJECT_ROOT" --check-homogeneous) || return 1 + if [[ "$shape" == "heterogeneous" ]]; then + python3 "$PROJECT_ROOT/tools/fleet/generate_fleet_compose.py" \ + "$fleet_host" --project-root "$PROJECT_ROOT" >/dev/null || return 1 + AIRSTACK_FLEET_COMPOSE_FILE="$PROJECT_ROOT/.airstack/generated/docker-compose.fleet.yaml" + local cur kept=() p + cur=$(resolve_launch_var COMPOSE_PROFILES "$@") + IFS=',' read -ra _fparr <<< "$cur" + for p in "${_fparr[@]}"; do + case "$p" in desktop|fleet|"") ;; *) kept+=("$p");; esac + done + kept+=("fleet") + export COMPOSE_PROFILES=$(IFS=','; echo "${kept[*]}") + log_info "--fleet $fleet_name is heterogeneous → generated per-robot services (profile 'fleet' replaces 'desktop')" + fi + return 0 +} + # Derive + export env vars from the parsed intent. Args: remaining CLI args # (scanned for --env-file when resolving current values). function apply_launch_intent { @@ -1103,6 +1247,17 @@ function apply_launch_intent { export URDF_FILE="$urdf" fi + # Fleet dispatch (RFC #380 §2): triggered by --fleet, or by an env / + # --env-file / .env FLEET_CONFIG_FILE (the opt-in path the test harness + # uses). No fleet anywhere = byte-identical legacy behavior. + local _fleet_sel="$AIRSTACK_INTENT_FLEET" + if [[ -z "$_fleet_sel" ]]; then + _fleet_sel=$(resolve_launch_var FLEET_CONFIG_FILE "$@") + fi + if [[ -n "$_fleet_sel" ]]; then + apply_fleet_intent "$_fleet_sel" "$@" || return 1 + fi + [[ -n "$AIRSTACK_INTENT_ROBOTS" ]] && export NUM_ROBOTS="$AIRSTACK_INTENT_ROBOTS" [[ -n "$AIRSTACK_INTENT_PLAY" ]] && export PLAY_SIM_ON_START="$AIRSTACK_INTENT_PLAY" [[ -n "$AIRSTACK_INTENT_AUTOLAUNCH" ]] && export AUTOLAUNCH="$AIRSTACK_INTENT_AUTOLAUNCH" @@ -1145,6 +1300,11 @@ function print_launch_config { ISAAC_SIM_SCRIPT_NAME ISAAC_SIM_USE_STANDALONE ISAAC_SIM_HEADLESS MS_AIRSIM_HEADLESS AIRSTACK_STACK_DIR AIRSTACK_STACK_ENTRY VERSION DOCKER_IMAGE_BUILD_MODE) + # FLEET_CONFIG_FILE only appears when a fleet is selected — the no-fleet + # effective config stays byte-identical to the pre-fleet contract. + local _fleet_cfg + _fleet_cfg=$(resolve_launch_var FLEET_CONFIG_FILE "$@") + [[ -n "$_fleet_cfg" ]] && keys+=(FLEET_CONFIG_FILE) local k v lines=() for k in "${keys[@]}"; do v=$(resolve_launch_var "$k" "$@") @@ -1163,6 +1323,17 @@ function print_launch_config { if [[ -n "$_stack_dir" ]]; then log_info " stack: dir=$_stack_dir entry=$(resolve_launch_var AIRSTACK_STACK_ENTRY "$@")" fi + if [[ -n "$_fleet_cfg" ]]; then + local _fleet_host="${_fleet_cfg#/root/AirStack/}" + [[ "$_fleet_host" != /* ]] && _fleet_host="$PROJECT_ROOT/$_fleet_host" + log_info " fleet: $_fleet_cfg — resolved robots:" + if [[ -f "$_fleet_host" ]]; then + python3 "$PROJECT_ROOT/tools/fleet/resolve_fleet.py" "$_fleet_host" \ + --project-root "$PROJECT_ROOT" --table 2>/dev/null | sed 's/^/ /' + else + log_warn " fleet file not found on the host side: $_fleet_host" + fi + fi echo "--- effective launch config ---" printf '%s\n' "${lines[@]}" @@ -1307,6 +1478,14 @@ function cmd_up { global_args+=(-f "$module_compose") fi + # Fleet overlay (RFC #380 §2): apply_fleet_intent regenerated per-robot + # services for a heterogeneous fleet — include them (the fleet profile is + # already active in COMPOSE_PROFILES; homogeneous fleets never set this). + if [[ -n "${AIRSTACK_FLEET_COMPOSE_FILE:-}" ]]; then + log_info "Fleet overlay active → including ${AIRSTACK_FLEET_COMPOSE_FILE#$PROJECT_ROOT/}" + global_args+=(-f "$AIRSTACK_FLEET_COMPOSE_FILE") + fi + print_launch_config "${global_args[@]}" if ! preflight_up global_args subcmd_args; then log_error "Preflight failed — not starting services. (AIRSTACK_SKIP_PREFLIGHT=1 to override.)" @@ -1482,15 +1661,25 @@ function cmd_down { check_docker local services=("$@") - + # Build compose arguments local compose_args=("-f" "$PROJECT_ROOT/docker-compose.yaml") - + + # Generated overlays (module mounts, fleet services) must be visible to + # `down` too — a fleet-generated service that `down` can't see becomes an + # invisible orphan spinning at full CPU (ddsrouters busy-loop without a + # sim /clock). --remove-orphans below is the belt-and-braces backstop. + local _gen + for _gen in "$PROJECT_ROOT/.airstack/generated/docker-compose.modules.yaml" \ + "$PROJECT_ROOT/.airstack/generated/docker-compose.fleet.yaml"; do + [ -f "$_gen" ] && compose_args+=("-f" "$_gen") + done + # Add services if specified if [ ${#services[@]} -gt 0 ]; then compose_args+=("down" "${services[@]}") else - compose_args+=("--profile" "*" "down") + compose_args+=("--profile" "*" "down" "--remove-orphans") fi log_info "Shutting down services: ${services[*]:-all}" @@ -1838,7 +2027,7 @@ function register_builtin_commands { COMMAND_HELP["image-pull"]="Pull Docker Compose service images from a registry" COMMAND_HELP["images"]="List Docker images filtered by PROJECT_NAME from .env" COMMAND_HELP["image-delete"]="Delete all Docker images matching PROJECT_NAME (prompts unless -y)" - COMMAND_HELP["up"]="Start services [--sim isaac|airsim] [--robots N] [--headless] [--play|--no-play] [--no-autolaunch] [--wait] [--dry-run]" + COMMAND_HELP["up"]="Start services [--sim isaac|airsim] [--robots N] [--stack NAME] [--fleet NAME] [--headless] [--play|--no-play] [--no-autolaunch] [--wait] [--dry-run]" COMMAND_HELP["down"]="down services" COMMAND_HELP["clean"]="Remove all ROS 2 build artifacts (build/, install/, log/)" COMMAND_HELP["connect"]="Connect to a running container (supports partial name matching)" diff --git a/airstack.yaml b/airstack.yaml new file mode 100644 index 000000000..9bc2b2e66 --- /dev/null +++ b/airstack.yaml @@ -0,0 +1,43 @@ +# airstack.yaml — the one hand-edited entry point for this checkout (RFC #380 §3). +# +# Answers "what does this checkout run?": the release it tracks, the fleet it +# flies, the simulator, and any module/stack sources beyond the defaults. +# `airstack sync` reads this file: it runs the module sync (modules.repos), +# fetches declared external stack repos into gitignored stacks/.external/, +# validates the declared fleet, and records what it resolved in +# .airstack/generated/effective_sources.yaml. +# +# Scope today (opt-in, RFC #380 scales 1–2): +# - `.env` stays HAND-EDITED. Generating .env from this file is future work; +# until then airstack.yaml layers on top of it and never rewrites it. +# - `release:` is informational: release-SET resolution (trunk + pinned +# module versions from a registry) is registry work (RFC #379 §7). A +# stack's pinned modules.repos is already a localized release set. +# - The declared fleet is validated by `airstack sync`; select it at launch +# with `airstack up --fleet ` (deriving the launch default from this +# file is future work — launch behavior without --fleet is unchanged). + +# Informational: the trunk release this checkout tracks (see `.env` VERSION). +release: "0.19.0-alpha" + +# The fleet this checkout flies (config/fleets/*.yaml — RFC #380 §2). +# sim_one_default == today's defaults: one quad_default on full_default. +fleet: config/fleets/sim_one_default.yaml + +# isaacsim | msairsim | none (real hardware). Informational selector today — +# `airstack up --sim isaac|airsim` is what actually swaps compose profiles. +sim: isaacsim + +# Module ADDITIONS beyond the release pins (RFC #380 §3). Each entry is +# upserted into modules.repos by `airstack sync` (which names every deviation): +# my_planner: {path: ../my_planner} # local checkout +# asm_optitrack: {repo: "git@github.com:castacks/asm_optitrack.git", version: v0.1.0} +# A bare {version: ...} without repo: needs registry resolution — not +# implemented yet; sync names it as an error. +modules: {} + +# External STACK repos (RFC #380 §3): fetched by `airstack sync` into +# gitignored stacks/.external//; fleets then reference their stacks as +# /. Refs must be pinned (tag or SHA — never a branch): +# swarm_stacks: {repo: "git@github.com:airlab-internal/swarm-stacks.git", ref: v1.3} +stacks: {} diff --git a/config/fleets/sim_one_default.yaml b/config/fleets/sim_one_default.yaml new file mode 100644 index 000000000..c7c7347b5 --- /dev/null +++ b/config/fleets/sim_one_default.yaml @@ -0,0 +1,26 @@ +# Fleet: sim_one_default (RFC #380 §2) — today's default checkout, as a fleet. +# +# One quad_default flying the full_default stack in the default sim scene: +# resolves EXACTLY like the legacy path (robot_1 on domain 1, full autonomy) — +# `tests/meta/test_fleet_contract.py` pins that parity. This is the fleet +# `airstack.yaml` declares; select it at launch with: +# +# airstack up --fleet sim_one_default --sim isaac +# +# Homogeneous fleet: deploy.replicas stamps it (NUM_ROBOTS derived from the +# robot count) — no generated compose needed. + +defaults: + vehicle: quad_default + stack: stacks/full_default + +robots: + robot_1: + spawn: [0, 0, 0.07] # sim-only; ignored on hardware + +sim: + scene: default # the Pegasus "Default Environment" + +network: + domain_policy: auto # robot N → domain N (today's rule) + gossip_domain: 99 diff --git a/config/fleets/sim_three_mixed.yaml b/config/fleets/sim_three_mixed.yaml new file mode 100644 index 000000000..c18dcb623 --- /dev/null +++ b/config/fleets/sim_three_mixed.yaml @@ -0,0 +1,45 @@ +# Fleet: sim_three_mixed (RFC #380 §2) — the reference HETEROGENEOUS fleet. +# +# Three quads, three different brains — software heterogeneity plus a split +# placement, in one file: +# +# robot_1 full_default everything onboard (the baseline topology) +# robot_2 lite_default onboard-lite, unsplit (no global layer at all) +# robot_3 lite_offload_global THE SPLIT CASE: its stack has onboard/offboard +# entry points + bridge.yaml; hosts: places the +# offboard half on the ground host `gcs` +# +# Heterogeneous ⇒ deploy.replicas cannot stamp it. Generate per-robot services: +# +# airstack fleet generate sim_three_mixed +# airstack up --fleet sim_three_mixed --sim isaac +# +# (`airstack up --fleet` regenerates and auto-includes the generated compose.) +# Spawn positions mirror row_spawn_configs(3): a row along X centered at the +# origin, 2 m apart. + +defaults: + vehicle: quad_default + stack: stacks/full_default + +robots: + robot_1: + spawn: [-2, 0, 0.07] + robot_2: + stack: stacks/lite_default + spawn: [0, 0, 0.07] + robot_3: + stack: stacks/lite_offload_global # split stack (onboard/offboard + bridge.yaml) + hosts: {offboard: gcs} # which ground host runs its offboard half + spawn: [2, 0, 0.07] + +ground: + gcs: {} # hosts robot_3's offboard half (global layer) + +sim: + scene: default + +network: + domain_policy: auto # robot N → domain N + gossip_domain: 99 + gcs_domain: 0 # domain the ground-host containers run on diff --git a/config/local/README.md b/config/local/README.md new file mode 100644 index 000000000..1ba94e174 --- /dev/null +++ b/config/local/README.md @@ -0,0 +1,26 @@ +# `config/local/` — this machine only (gitignored) + +Everything in this directory except this README is **gitignored**: it belongs +to one machine / one physical vehicle and must never be committed or shared +through trunk (RFC #380 §1, §3). + +``` +config/local/ +├── calibration// # per-UNIT intrinsics/extrinsics (SN-0042/, ...) +└── overrides.yaml # (future) 4th precedence layer, below CLI flags +``` + +## Calibration overlays (vehicle units) + +A vehicle **type** (`config/vehicles//`) is shared and committed; a +vehicle **unit** is one serial number whose calibration drifts and gets +re-measured in the field. A fleet entry's `unit: SN-0042` binds +`config/local/calibration/SN-0042/`, and `tools/fleet/resolve_fleet.py` +exports it to the robot container as `CALIBRATION_DIR` +(`/root/AirStack/config/local/calibration/SN-0042`). Recalibrating writes +here — never into the shared vehicle package. + +No enforced file layout yet: put the camera intrinsics / extrinsics files +your drivers consume in the unit directory and point the driver config at +`$CALIBRATION_DIR`. `CALIBRATION_DIR` is empty when the fleet entry declares +no `unit:`. diff --git a/config/vehicles/README.md b/config/vehicles/README.md new file mode 100644 index 000000000..018e43745 --- /dev/null +++ b/config/vehicles/README.md @@ -0,0 +1,48 @@ +# Vehicle types (`config/vehicles/`) — RFC #380 §1 + +A **vehicle type** is a data-only description of a class of airframe: which +platform flies it, which URDF describes it, and which sensors it carries — +with each sensor's **real driver and sim representation declared in one +entry**, so a sim-generated wiring baseline diffs cleanly against a hardware +bring-up. One directory per type: + +``` +config/vehicles// +└── vehicle.yaml # platform, airframe, sensor suite, sim_asset +``` + +In-tree types ship with trunk (`quad_default` is the reference); other +vehicles arrive as data modules (`type: data` in RFC #379's module mechanism). + +## Type vs. unit + +| | Vehicle **type** | Vehicle **unit** | +|---|---|---| +| What | A class of airframe (`quad_default`) | One physical serial number (`SN-0042`) | +| Content | URDF, sensor suite, tuning | Intrinsics/extrinsics **calibration** | +| Lives in | `config/vehicles//` — shared, committed | `config/local/calibration//` — **gitignored, per machine** | +| Changes when | The design changes | The camera gets re-mounted / re-calibrated in the field | + +A fleet entry binds the two: `vehicle: quad_default` selects the type, +`unit: SN-0042` binds `config/local/calibration/SN-0042/` (exported to the +container as `CALIBRATION_DIR`). Recalibrating a unit never edits the shared +package — see [`config/local/README.md`](../local/README.md). + +## Fields (`vehicle.yaml`) + +- `platform:` — the platform class (code layer). `px4_multirotor` is the only + platform today; platform *modules* are RFC #380 Part 2 (future). +- `airframe.base_urdf:` — **pass-through form**: the existing hand-built URDF, + package-relative (what `URDF_FILE` carries today). RFC #380 §1's generated + form (base xacro + `mounts/` extrinsics → URDF) is future work; when it + lands, `base_xacro:` replaces `base_urdf:` and monolithic URDFs retire. +- `sensors:` — list of `{type, id, frame, driver, sim}` entries. `id` names + the `sensors//*` topic namespace (interface conventions); `driver` is + the hardware-side module, `sim` the Isaac/Pegasus subgraph that stands in + for it. The fleet spawner reads this list (e.g. any `lidar*` entry enables + the RTX lidar subgraph — the `ENABLE_LIDAR` env var equivalent). +- `sim_asset:` — the sim asset the spawner instantiates. + +Consumed by `tools/fleet/resolve_fleet.py` (URDF/vehicle exports per robot) +and `simulation/isaac-sim/launch_scripts/fleet_spawn.py` (spawn + sensor +toggles). See [docs/development/fleets.md](../../docs/development/fleets.md). diff --git a/config/vehicles/quad_default/vehicle.yaml b/config/vehicles/quad_default/vehicle.yaml new file mode 100644 index 000000000..dd309deb3 --- /dev/null +++ b/config/vehicles/quad_default/vehicle.yaml @@ -0,0 +1,45 @@ +# Vehicle TYPE: quad_default (RFC #380 §1) +# +# The default AirStack quadrotor — the Pegasus Iris airframe carrying the +# stereo camera + 3D lidar suite every reference stack is wired for. A vehicle +# manifest is DATA: it describes a class of airframe (what sensors it carries, +# which driver runs each on hardware and which sim subgraph represents it), +# never code. Per-serial calibration does NOT live here — see +# config/vehicles/README.md (type vs. unit) and config/local/. +# +# Consumed by: +# - tools/fleet/resolve_fleet.py → URDF_FILE / VEHICLE exports per robot +# - simulation/isaac-sim/launch_scripts/fleet_spawn.py → sensor toggles +# - (future) doctor's (vehicle, stack) pairing check: a stack consuming +# sensors/front_stereo/* requires a sensor with id front_stereo here. + +name: quad_default +platform: px4_multirotor + +airframe: + # PASS-THROUGH form: points at the existing hand-built URDF unchanged. + # RFC #380 §1's target is a GENERATED URDF (base xacro + sensor mounts); + # until that generator exists, base_urdf names the monolithic file the + # robot_state_publisher already loads (package-relative, as URDF_FILE). + base_urdf: robot_descriptions/iris/urdf/iris_with_sensors.pegasus.robot.urdf + # ms-airsim runs a reduced stereo-only URDF today; `airstack up --sim airsim` + # keeps exporting it (env wins over the fleet resolver's URDF_FILE). + +# One entry per sensor: the real driver and the sim representation declared +# together (one source of truth for both worlds). `id` is the sensors/* topic +# namespace segment and the name wiring snapshots normalize driver nodes to. +sensors: + - type: stereo_cam + id: front_stereo # topics: sensors/front_stereo/{left,right}/{image_rect,camera_info} + frame: base_link_ZED_X # URDF link (camera_left / camera_right children) + driver: {module: zed_wrapper} # hardware: ZED X driver (the zed-l4t service today) + sim: {pegasus: zed_stereo_camera} # add_zed_stereo_camera_subgraph (pegasus_app.spawn_drone) + + - type: lidar_3d + id: ouster # topics: sensors/ouster/point_cloud_raw (filtered: sensors/ouster/point_cloud) + frame: ouster # URDF link, mounted via lidar_mount + driver: {module: ouster_ros} + sim: {pegasus: rtx_lidar, config: ouster_os1} # add_rtx_lidar_subgraph + +# Pegasus Iris asset spawned by the fleet spawner (pegasus_app.DEFAULT_DRONE_USD). +sim_asset: iris.usd diff --git a/docs/development/fleets.md b/docs/development/fleets.md new file mode 100644 index 000000000..5a64d679e --- /dev/null +++ b/docs/development/fleets.md @@ -0,0 +1,226 @@ +# AirStack Fleets + +A **fleet file** (`config/fleets/*.yaml`) declares a whole deployment in one +readable document: who exists, which body each robot flies (vehicle), which +brain it runs (stack), and which ground hosts run each split stack's offboard +half ([RFC #380 §2](https://github.com/castacks/AirStack/discussions/380)). +The same file drives simulation (spawn positions, scene) and hardware +(identity, placement) — sim vs. real is a deployment mode of one artifact. + +**Everything here is opt-in.** No `--fleet` flag and no `FLEET_CONFIG_FILE` +env var ⇒ behavior is byte-identical to the legacy path +(`NUM_ROBOTS` + `robot_name_map` + `ISAAC_SIM_SCRIPT_NAME`), which remains +the default and fully supported. + +## The hierarchy + +```text +platform class → vehicle type → vehicle unit → robot instance → fleet +(code: px4_ (data: config/ (calibration: (one entry in (config/ + multirotor) vehicles//) config/local/ a fleet file) fleets/.yaml) + calibration//) +``` + +- **Platform class** — code: interface, controller, safety behaviors. + `px4_multirotor` is the only platform today; platform *modules* are + RFC #380 Part 2 (future work). +- **Vehicle type** — data: `config/vehicles//vehicle.yaml` (URDF, + sensor suite with each sensor's real driver + sim representation declared + together, sim asset). See + [config/vehicles/README.md](https://github.com/castacks/AirStack/tree/develop/config/vehicles). +- **Vehicle unit** — one serial number's calibration, in the gitignored + `config/local/calibration//` overlay; a fleet entry binds it with + `unit:` (exported as `CALIBRATION_DIR`). +- **Robot instance** — the composition *vehicle × stack × unit*, one entry in + a fleet file. +- **Fleet** — all robots + named ground hosts + network policy + sim scene. + +## File tour + +`config/fleets/sim_one_default.yaml` — today's default checkout as a fleet +(one `quad_default` on `full_default`; resolves identically to the legacy +path — a contract test pins the parity): + +```yaml +defaults: {vehicle: quad_default, stack: stacks/full_default} +robots: + robot_1: {spawn: [0, 0, 0.07]} +sim: {scene: default} +network: {domain_policy: auto, gossip_domain: 99} +``` + +`config/fleets/sim_three_mixed.yaml` — the reference **heterogeneous** fleet: + +```yaml +defaults: {vehicle: quad_default, stack: stacks/full_default} +robots: + robot_1: {spawn: [-2, 0, 0.07]} # full_default (defaults) + robot_2: {stack: stacks/lite_default, spawn: [0, 0, 0.07]} + robot_3: + stack: stacks/lite_offload_global # a SPLIT stack + hosts: {offboard: gcs} # placement of its offboard half + spawn: [2, 0, 0.07] +ground: + gcs: {} # named ground host +sim: {scene: default} +network: {domain_policy: auto, gossip_domain: 99, gcs_domain: 0} +``` + +Per-robot keys: `vehicle`, `stack`, `unit`, `spawn` (sim-only, `[x, y, z]` m), +`hosts` (split placement), `overrides` (leaf values only — accepted by the +schema, launch-time application is future work). `defaults:` keeps +homogeneous fleets terse. A robot needing different *topology* points at a +different stack folder — that division keeps fleet files skimmable. + +A fleet's `stack:` values resolve first as checkout paths (`stacks/...`), +then as `/` against external stack repos declared in +`airstack.yaml` and fetched by `airstack sync` into gitignored +`stacks/.external//` (RFC #380 §3). + +## Running a fleet + +```bash +airstack fleet list # what exists, robots, shape +airstack up --fleet sim_one_default --sim isaac # homogeneous: replicas +airstack up --fleet sim_three_mixed --sim isaac # heterogeneous: generated services +airstack ready +``` + +`--fleet ` (or an explicit `FLEET_CONFIG_FILE` env var, which the test +harness uses): + +1. **validates** the fleet file (named errors: unknown stack/vehicle, a + `hosts:` role with no matching entry point, a host naming no `ground:` + entry, a split stack used without `hosts:`, bad `spawn`, unknown keys); +2. exports **`FLEET_CONFIG_FILE`** (the container path — + `config/` is bind-mounted read-only at `/root/AirStack/config`); +3. derives **`NUM_ROBOTS`** from the robot count (leaf-value precedence: an + explicitly set env `NUM_ROBOTS` still wins, with an override banner); +4. on Isaac, switches an untouched-default `ISAAC_SIM_SCRIPT_NAME` to the + generic **fleet spawner** (`fleet_spawn.py`) — an explicit env value wins; +5. for **heterogeneous** fleets, regenerates + `.airstack/generated/docker-compose.fleet.yaml` and includes it, swapping + the `desktop` profile for the generated services' `fleet` profile. + +The effective-config dump gains a `FLEET_CONFIG_FILE=` line and a resolved +robot table (robot, domain, vehicle, stack, entry, hosts, spawn) — only when +a fleet is selected. + +## How a container resolves its identity (opt-in mechanics) + +`robot/docker/.bashrc` branches on `FLEET_CONFIG_FILE`: + +- **Set** → `tools/fleet/resolve_fleet.py` resolves the container's whole + fleet entry and exports `ROBOT_NAME`, `ROS_DOMAIN_ID`, + `AIRSTACK_STACK_DIR`, `AIRSTACK_STACK_ENTRY`, `URDF_FILE`, `VEHICLE`, + `CALIBRATION_DIR`. Identity comes from the container name / hostname + (exact robot key, else the trailing replica index — the same convention as + the legacy map). Pre-set env still wins per variable: an explicit + `ROBOT_NAME` skips resolution entirely (heterogeneous-fleet services set it + explicitly), and non-empty `ROS_DOMAIN_ID` / `AIRSTACK_STACK_DIR` / + `URDF_FILE` keep their values. Resolution failure warns and falls back to + the legacy resolver. +- **Unset/empty** → the legacy `robot_name_map` resolver runs, untouched. + +`network.domain_policy: auto` (the only implemented policy) assigns robot N +(1-based file order) → domain N — today's rule, byte-compatible with the +legacy resolver for `robot_1..robot_N` fleets. + +## Homogeneous vs. heterogeneous + +`deploy.replicas` can only stamp **identical** containers, so: + +| Fleet shape | Mechanism | +|---|---| +| Homogeneous (same vehicle + stack everywhere, no `hosts:`, no `ground:`) | `deploy.replicas` (`NUM_ROBOTS` derived); each replica resolves itself via `FLEET_CONFIG_FILE`. `airstack fleet generate` detects this and writes nothing. | +| Heterogeneous | `airstack fleet generate ` → `.airstack/generated/docker-compose.fleet.yaml`: one **self-contained** service per robot (extending nothing; explicit `ROBOT_NAME` / `ROS_DOMAIN_ID` / `AIRSTACK_STACK_DIR` / `AIRSTACK_STACK_ENTRY` / `FLEET_CONFIG_FILE` env) plus one per (ground host × offboard tenant), all under the `fleet` compose profile. `airstack up --fleet` regenerates and includes it automatically. | + +## Split placement (`hosts:`) + +A **split is a stack shape** ([stacks guide](stacks.md#split-stacks-and-bridgeyaml-rfc-380-2)): +multiple launch entry points plus a `bridge.yaml`. The fleet decides *where +each half runs*: + +```yaml +robot_3: + stack: stacks/lite_offload_global + hosts: {offboard: gcs} +ground: + gcs: {} +``` + +- `robot_3`'s container gets `AIRSTACK_STACK_ENTRY=onboard` (a robot with + `hosts:` runs the onboard entry point). +- The ground host `gcs` gets a generated service (`gcs-robot_3`) running the + **same stack** with `AIRSTACK_STACK_ENTRY=offboard`, `ROBOT_NAME=robot_3` + (the tenant it serves), and `ROS_DOMAIN_ID` = the fleet's `gcs_domain` + (default 0) — mirroring the legacy `robot-offboard` service. +- Every `hosts:` role must match an entry-point launch file of the robot's + stack, and every named host must exist under `ground:` — both are named + validation errors. Doctor's bridge hard-gate (no control-setpoint / + trajectory-group names in any `bridge.yaml`) holds unchanged for every + split stack a fleet places. +- Legacy `AUTONOMY_ROLE` never enters: the entry point *is* the role. + +## Simulation + +- **Isaac Sim** — `simulation/isaac-sim/launch_scripts/fleet_spawn.py` + replaces the hardcoded one-/multi-drone example scripts when a fleet is + selected: spawn positions and per-vehicle sensor toggles (any `lidar*` + sensor in the vehicle manifest enables the RTX lidar subgraph — the + per-vehicle `ENABLE_LIDAR` equivalent) come from the fleet file; the scene + comes from `sim.scene` (`default` = the Pegasus "Default Environment", a + `SIMULATION_ENVIRONMENTS` key, or a `.usd` path). Vehicles are pass-through + today: every `px4_multirotor` spawns the Pegasus Iris asset. +- **ms-airsim** — `generate_settings.py` already consumes `NUM_ROBOTS`, which + the fleet derives; nothing more is needed (spawn spacing stays the + simulator's `AIRSIM_SPAWN_SPACING` grid for now). +- **Test harness** — `airstack test ... --fleet ` passes + `FLEET_CONFIG_FILE` + the derived `NUM_ROBOTS` through `airstack_env` + (Isaac runs pin `fleet_spawn.py`). Without `--fleet`, `--num-robots` + behaves exactly as before. + +## Top level: `airstack.yaml` + `airstack sync` + +`airstack.yaml` (checked in, hand-edited) answers "what does this checkout +run?": `release` (informational until registry-backed release sets land), +`fleet`, `sim`, `modules` (additions beyond the pins — `{path: ...}` or +`{repo: ..., version: }`), and `stacks` (external stack repo aliases). +`airstack sync` reads it: upserts module additions into `modules.repos` +(naming every deviation), runs the module sync, fetches external stack repos +into `stacks/.external//` (pinned refs only), validates the declared +fleet, and records the result in +`.airstack/generated/effective_sources.yaml`. + +Deliberately **not** done yet (future work): generating `.env` (it stays +hand-edited; `airstack.yaml` layers on top and never rewrites it), resolving +bare `{version: ...}` module pins against a registry, and deriving the +launch-time fleet default from `airstack.yaml` (select fleets explicitly with +`--fleet`). + +## Migration table (RFC #380 §3) + +| Legacy env var | Under a fleet | Status | +|---|---|---| +| `NUM_ROBOTS` | **Derived** from the fleet's robot count | explicit env still wins (banner) | +| `ROBOT_NAME_MAP_CONFIG_FILE` | Absorbed: identity resolves from the fleet entry | legacy resolver remains the no-fleet default | +| `AUTONOMY_ROLE` | Absorbed: derived from `hosts:` + the stack's entry points | legacy dispatch remains without a stack/fleet | +| `URDF_FILE` | From the vehicle's `airframe.base_urdf` (pass-through form; xacro generation is future) | explicit env still wins | +| `ISAAC_SIM_SCRIPT_NAME` | **Derived**: the generic fleet spawner | explicit env still wins | +| `ROBOT_NAME` / `ROS_DOMAIN_ID` | Resolved per robot (`domain_policy: auto` = robot N → domain N) | pre-set env still wins | +| `VERSION` | Unchanged (`release:` is informational until the registry lands) | `.env` stays hand-edited | + +## CLI reference + +```bash +airstack fleet list # fleets: robots, vehicles, stacks, shape +airstack fleet generate # per-robot compose for heterogeneous fleets +airstack up --fleet [...] # validate + export + (re)generate + up +airstack sync # airstack.yaml → modules, external stacks, + # fleet validation, effective_sources.yaml +python3 tools/fleet/resolve_fleet.py config/fleets/.yaml --table # inspect +``` + +Contract tests: `tests/meta/test_fleet_contract.py` (resolver parity with the +legacy map, generation determinism, split placement, the bridge hard-gate, +precedence, named schema errors). diff --git a/docs/development/stacks.md b/docs/development/stacks.md index b44db4ac6..c76bf8880 100644 --- a/docs/development/stacks.md +++ b/docs/development/stacks.md @@ -148,6 +148,13 @@ stacks/lite_offload_global/ `... :offboard` on the ground host. Today's coarse `AUTONOMY_ROLE` trichotomy becomes "which entry point does this host run"; a third machine is just another entry file + bridge section. +- **Or declare the placement in a fleet** (RFC #380 §2): a fleet entry with + `stack: stacks/lite_offload_global` and `hosts: {offboard: gcs}` derives + both halves — the robot's service gets the `onboard` entry, the named + ground host's service gets the same stack with + `AIRSTACK_STACK_ENTRY=offboard` (`airstack fleet generate`; the reference + is `config/fleets/sim_three_mixed.yaml`, robot_3). See + [AirStack Fleets](fleets.md). - **Generate the router config** from the bridge list (never hand-edit the output): diff --git a/mkdocs.yml b/mkdocs.yml index 84f3fb27c..b0f4d9ad8 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -82,6 +82,7 @@ nav: - AI Agent Guide: docs/development/advanced/ai_agent_guide.md - AirStack Modules: docs/development/modules.md - AirStack Stacks: docs/development/stacks.md + - AirStack Fleets: docs/development/fleets.md - AirStack CLI Tool: - Extending: docs/development/advanced/airstack-cli/extending.md - Architecture: docs/development/advanced/airstack-cli/architecture.md diff --git a/overrides/l4t-px4-realrobot.env b/overrides/l4t-px4-realrobot.env index 6da88cf8d..c2ebecbc6 100644 --- a/overrides/l4t-px4-realrobot.env +++ b/overrides/l4t-px4-realrobot.env @@ -1,10 +1,17 @@ # Real-robot deployment on an NVIDIA Jetson (aarch64 / l4t) with a PX4 flight -# controller (e.g. Cube Orange) over serial. +# controller (e.g. Cube Orange) over serial. # Build (first time / after image changes): # airstack image-build --profile l4t robot-l4t # Run: # airstack up --env-file overrides/l4t-px4-realrobot.env robot-l4t +# +# This file selects HARDWARE (Jetson profile, serial FCU, bag storage) — not +# topology. Topology selection is orthogonal and layers on the same command: +# --stack full_default instead of AUTONOMY_ROLE (docs/development/stacks.md) +# --fleet fleet-file identity/placement resolution; replaces +# the hostname→robot_name mapping and, per robot, +# AUTONOMY_ROLE/URDF_FILE (docs/development/fleets.md) # Only bring up the Jetson stack (robot-l4t + zed-l4t). COMPOSE_PROFILES="l4t" diff --git a/overrides/ms-airsim.env b/overrides/ms-airsim.env index 125471c66..decc30909 100644 --- a/overrides/ms-airsim.env +++ b/overrides/ms-airsim.env @@ -1,4 +1,9 @@ # overrides specific to running airsim # run as airstack up --env-file overrides/ms-airsim.env; OR docker compose --env-file .env --env-file overrides/ms-airsim.env up +# +# Equivalent intent flag (derives both values below): airstack up --sim airsim +# This file selects a SIMULATOR, not a topology — stack/fleet selection is +# orthogonal: add --stack (docs/development/stacks.md) or +# --fleet (docs/development/fleets.md) on the same command line. COMPOSE_PROFILES="desktop,ms-airsim" -URDF_FILE="robot_descriptions/iris/urdf/iris_stereo.ms-airsim.urdf" \ No newline at end of file +URDF_FILE="robot_descriptions/iris/urdf/iris_stereo.ms-airsim.urdf" diff --git a/robot/docker/.bashrc b/robot/docker/.bashrc index a62e5f18b..45a983a77 100755 --- a/robot/docker/.bashrc +++ b/robot/docker/.bashrc @@ -69,6 +69,59 @@ function cws(){ source /opt/ros/jazzy/setup.bash sws # source the ROS2 workspace by default +# --- Fleet resolution (RFC #380 §2, OPT-IN) --- +# When FLEET_CONFIG_FILE is set (airstack up --fleet ), resolve this +# container's WHOLE fleet entry — name, domain, stack placement, vehicle, +# calibration overlay — via tools/fleet/resolve_fleet.py. Contract: +# - pre-set ROBOT_NAME skips resolution entirely (same guard as the legacy +# branch below; heterogeneous-fleet services set it explicitly); +# - pre-set non-empty ROS_DOMAIN_ID / AIRSTACK_STACK_DIR(+ENTRY) / URDF_FILE +# win over the fleet's values (leaf-value precedence); +# - resolution failure warns and falls through to the legacy resolver. +# FLEET_CONFIG_FILE unset or empty = byte-identical legacy behavior. +if [ -n "${FLEET_CONFIG_FILE:-}" ] && [ -z "${ROBOT_NAME:-}" ]; then + if [ "$ROBOT_NAME_SOURCE" == "hostname" ]; then + fleet_identity=$(hostname) + else + # container-name resolution (same technique as the legacy branch; + # needs docker >= 29) + fleet_identity=$(host $(host $(hostname) | awk '{print $NF}') | awk '{print $NF}' | awk -F . '{print $1}') + CONTAINER_NAME=":$fleet_identity" + fi + fleet_resolver="$HOME/AirStack/tools/fleet/resolve_fleet.py" + if [ -f "$fleet_resolver" ]; then + _fleet_prev_domain="${ROS_DOMAIN_ID:-}" + _fleet_prev_stack_dir="${AIRSTACK_STACK_DIR:-}" + _fleet_prev_stack_entry="${AIRSTACK_STACK_ENTRY:-}" + _fleet_prev_urdf="${URDF_FILE:-}" + _fleet_exports=$(python3 "$fleet_resolver" "$FLEET_CONFIG_FILE" --name "$fleet_identity") + if [ $? -eq 0 ] && [ -n "$_fleet_exports" ]; then + eval "$_fleet_exports" + export ROBOT_NAME VEHICLE CALIBRATION_DIR + # pre-set env wins per variable + [ -n "$_fleet_prev_domain" ] && ROS_DOMAIN_ID="$_fleet_prev_domain" + export ROS_DOMAIN_ID + if [ -n "$_fleet_prev_stack_dir" ]; then + AIRSTACK_STACK_DIR="$_fleet_prev_stack_dir" + AIRSTACK_STACK_ENTRY="$_fleet_prev_stack_entry" + fi + export AIRSTACK_STACK_DIR AIRSTACK_STACK_ENTRY + [ -n "$_fleet_prev_urdf" ] && URDF_FILE="$_fleet_prev_urdf" + export URDF_FILE + else + echo "WARNING: fleet resolution failed for '$fleet_identity' via" \ + "$FLEET_CONFIG_FILE (resolver error above) — falling back to the" \ + "legacy robot_name_map resolver." + fi + unset _fleet_prev_domain _fleet_prev_stack_dir _fleet_prev_stack_entry \ + _fleet_prev_urdf _fleet_exports + else + echo "WARNING: FLEET_CONFIG_FILE=$FLEET_CONFIG_FILE is set but $fleet_resolver" \ + "is missing (tools/fleet should be bind-mounted) — falling back to the" \ + "legacy robot_name_map resolver." + fi +fi + # If ROBOT_NAME is pre-set (e.g. via docker compose), keep it. # Otherwise extract robot name and ROS domain ID from the container/hostname mapping. if [ -z "${ROBOT_NAME:-}" ]; then diff --git a/robot/docker/robot-base-docker-compose.yaml b/robot/docker/robot-base-docker-compose.yaml index 895cc9e5f..92e95d2eb 100644 --- a/robot/docker/robot-base-docker-compose.yaml +++ b/robot/docker/robot-base-docker-compose.yaml @@ -26,6 +26,10 @@ services: # Empty = legacy AUTONOMY_ROLE dispatch in robot.launch.xml. - AIRSTACK_STACK_DIR=${AIRSTACK_STACK_DIR:-} - AIRSTACK_STACK_ENTRY=${AIRSTACK_STACK_ENTRY:-stack} + # Fleet dispatch (RFC #380): set by `airstack up --fleet ` to the + # CONTAINER path of the fleet file (/root/AirStack/config/fleets/...). + # Empty = legacy robot_name_map resolution in .bashrc. + - FLEET_CONFIG_FILE=${FLEET_CONFIG_FILE:-} volumes: # display stuff - $HOME/.Xauthority:/.Xauthority @@ -45,6 +49,13 @@ services: - ../ros_ws:/root/AirStack/robot/ros_ws:rw # reference stack folders (launch entry points read via AIRSTACK_STACK_DIR) - ../../stacks:/root/AirStack/stacks:rw + # fleet + vehicle configs and the fleet resolver (.bashrc uses them only + # when FLEET_CONFIG_FILE is set — RFC #380) + - ../../config:/root/AirStack/config:ro + - ../../tools/fleet:/root/AirStack/tools/fleet:ro + # Generated artifacts split-stack entries read at launch (bridge-derived + # DDS-router configs from tools/gen_dds_router.py). + - ../../.airstack/generated:/root/AirStack/.airstack/generated:ro # bags - ../bags:/bags:rw diff --git a/simulation/isaac-sim/docker/docker-compose.yaml b/simulation/isaac-sim/docker/docker-compose.yaml index cd90452b3..59e5fba1c 100644 --- a/simulation/isaac-sim/docker/docker-compose.yaml +++ b/simulation/isaac-sim/docker/docker-compose.yaml @@ -48,6 +48,9 @@ services: - PLAY_SIM_ON_START=${PLAY_SIM_ON_START} - NUM_ROBOTS=${NUM_ROBOTS:-1} - ENABLE_LIDAR=${ENABLE_LIDAR:-false} + # Fleet spawner input (RFC #380): fleet_spawn.py maps the robot-container + # path onto this container's /isaac-sim/AirStack checkout mount. + - FLEET_CONFIG_FILE=${FLEET_CONFIG_FILE:-} - ISAAC_SIM_HEADLESS=${ISAAC_SIM_HEADLESS:-false} # Pegasus physics tuning — read by pegasus/simulator/params.py - PX4_PHYSICS_HZ=${PX4_PHYSICS_HZ:-100} diff --git a/simulation/isaac-sim/launch_scripts/fleet_spawn.py b/simulation/isaac-sim/launch_scripts/fleet_spawn.py new file mode 100755 index 000000000..fbc7d89a9 --- /dev/null +++ b/simulation/isaac-sim/launch_scripts/fleet_spawn.py @@ -0,0 +1,171 @@ +#!/usr/bin/env python +"""Generic Isaac Sim fleet spawner (RFC #380 §2). + +Replaces the hardcoded one-/multi-drone example launch scripts when a fleet is +selected: spawn positions, per-robot vehicle (pass-through → the Pegasus Iris +asset today), sensor toggles, and the scene all come from the fleet file named +by ``FLEET_CONFIG_FILE`` (``airstack up --fleet `` exports it and +switches ``ISAAC_SIM_SCRIPT_NAME`` to this script when the default was +untouched). + +``FLEET_CONFIG_FILE`` carries the ROBOT-container path +(``/root/AirStack/config/fleets/.yaml``); this container mounts the +checkout at ``/isaac-sim/AirStack``, so the path is remapped onto that mount. + +Fleet fields consumed: + - ``robots..spawn`` — [x, y, z] in meters (default [0, 0, 0.07]) + - ``robots.`` — order defines domain_id / vehicle_id (robot N → + domain N, ``network.domain_policy: auto``) + - vehicle manifests (``config/vehicles//vehicle.yaml``) — any ``lidar*`` + sensor entry enables the RTX lidar subgraph for that robot (the + ``ENABLE_LIDAR`` env-var equivalent, but per vehicle); any ``stereo_cam`` + entry enables the ZED camera subgraph. + - ``sim.scene`` — ``default`` (or unset) = the Pegasus "Default Environment" + (matching the example scripts); any other value: a + ``SIMULATION_ENVIRONMENTS`` key, or a ``.usd`` path/URL used verbatim. + +Env (see pegasus_app.py): ISAAC_SIM_LIVESTREAM, ISAAC_SIM_HEADLESS, +PLAY_SIM_ON_START. + +Import-order contract: everything Isaac/Pegasus (including pegasus_app, which +imports ``carb`` at module scope) is imported inside ``main()`` — the +fleet-parsing helpers below are stdlib+PyYAML only, so this module parses and +imports without an Isaac install (unit tests exercise the mapping directly). +""" + +import os +import sys + +import yaml + +# Robot-container checkout root → this container's checkout mount. +ROBOT_CONTAINER_ROOT = "/root/AirStack" +ISAAC_CONTAINER_ROOT = "/isaac-sim/AirStack" + +DEFAULT_SPAWN_Z = 0.07 + + +def remap_fleet_path(fleet_config_file, isaac_root=ISAAC_CONTAINER_ROOT): + """Map the robot-container FLEET_CONFIG_FILE path onto this container.""" + if fleet_config_file.startswith(ROBOT_CONTAINER_ROOT + "/"): + return isaac_root + fleet_config_file[len(ROBOT_CONTAINER_ROOT):] + return fleet_config_file + + +def load_yaml(path): + with open(path, encoding="utf-8") as f: + return yaml.safe_load(f) or {} + + +def vehicle_sensor_flags(project_root, vehicle_name): + """(has_stereo_cam, has_lidar) from the vehicle manifest's sensor list. + + A missing/invalid manifest keeps the permissive defaults (camera on, + lidar on) so a mis-mounted config degrades loudly-visibly, not silently + sensor-less. + """ + manifest = os.path.join(project_root, "config", "vehicles", vehicle_name, "vehicle.yaml") + if not os.path.isfile(manifest): + print(f"[fleet_spawn] WARNING: no vehicle manifest at {manifest} — " + f"defaulting to camera+lidar on") + return True, True + sensors = load_yaml(manifest).get("sensors") or [] + has_cam = any("cam" in str(s.get("type", "")) for s in sensors if isinstance(s, dict)) + has_lidar = any("lidar" in str(s.get("type", "")) for s in sensors if isinstance(s, dict)) + return has_cam, has_lidar + + +def fleet_to_drone_configs(fleet, project_root): + """Fleet dict → PegasusApp drone_configs (pure function; unit-tested). + + Robot N (1-based file order) gets domain_id N — the ``domain_policy: auto`` + rule, matching the legacy resolver and ``row_spawn_configs``. + """ + robots = fleet.get("robots") or {} + if not robots: + raise ValueError("fleet has no robots: — nothing to spawn") + defaults = fleet.get("defaults") or {} + configs = [] + for i, (name, entry) in enumerate(robots.items(), start=1): + entry = entry or {} + spawn = entry.get("spawn", [0.0, 0.0, DEFAULT_SPAWN_Z]) + vehicle = entry.get("vehicle", defaults.get("vehicle", "")) + _has_cam, has_lidar = vehicle_sensor_flags(project_root, vehicle) + configs.append({ + "domain_id": i, # MAVLink port = 14540 + vehicle_id (= domain_id) + "robot_name": name, + "x_m": float(spawn[0]), + "y_m": float(spawn[1]), + "z_m": float(spawn[2]), + "lidar": has_lidar, + }) + if len(configs) == 1: + # Single-robot fleets must be byte-equivalent to the validated + # example_one script, including the historical single-drone prim and + # node names (multi-style names are for multi-drone scenes). + configs[0]["prim"] = "/World/base_link" + configs[0]["node_name"] = "PX4Multirotor" + return configs + + +def fleet_env_url(fleet, simulation_environments): + """Resolve ``sim.scene`` against Pegasus SIMULATION_ENVIRONMENTS.""" + scene = (fleet.get("sim") or {}).get("scene", "default") + if scene in (None, "", "default"): + return simulation_environments["Default Environment"] + if scene in simulation_environments: + return simulation_environments[scene] + if str(scene).endswith(".usd"): + return scene + raise ValueError( + f"sim.scene '{scene}' is neither a SIMULATION_ENVIRONMENTS key nor a .usd path " + f"(keys: {', '.join(sorted(simulation_environments))})" + ) + + +def main(): + fleet_config_file = os.environ.get("FLEET_CONFIG_FILE", "") + if not fleet_config_file: + print("[fleet_spawn] ERROR: FLEET_CONFIG_FILE is not set — this script is " + "selected by `airstack up --fleet `; use the example launch " + "scripts for fleetless runs.", file=sys.stderr) + return 1 + fleet_path = remap_fleet_path(fleet_config_file) + if not os.path.isfile(fleet_path): + print(f"[fleet_spawn] ERROR: fleet file not found: {fleet_path} " + f"(from FLEET_CONFIG_FILE={fleet_config_file})", file=sys.stderr) + return 1 + # /config/fleets/.yaml → + project_root = os.path.dirname(os.path.dirname(os.path.dirname(fleet_path))) + + fleet = load_yaml(fleet_path) + drone_configs = fleet_to_drone_configs(fleet, project_root) + print(f"[fleet_spawn] {os.path.basename(fleet_path)}: spawning " + f"{len(drone_configs)} drone(s): " + + ", ".join( + f"{c['robot_name']}@({c['x_m']:g},{c['y_m']:g},{c['z_m']:g})" + f"{' +lidar' if c['lidar'] else ''}" + for c in drone_configs)) + + # ── Isaac/Pegasus imports — deferred (see module docstring) ────────────── + sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + from pegasus_app import create_simulation_app + + # Must be created before any omni/pegasus imports. + create_simulation_app() + + from pegasus.simulator.params import SIMULATION_ENVIRONMENTS # noqa: E402 + from pegasus_app import PegasusApp # noqa: E402 + + PegasusApp( + env_url=fleet_env_url(fleet, SIMULATION_ENVIRONMENTS), + stage_scale=1.0, + drone_configs=drone_configs, + # Per-robot "lidar" keys above override this app-level default. + enable_lidar=False, + ).run() + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/stacks/lite_offload_global/README.md b/stacks/lite_offload_global/README.md index 1dd4f27ea..a800e9520 100644 --- a/stacks/lite_offload_global/README.md +++ b/stacks/lite_offload_global/README.md @@ -72,7 +72,13 @@ a running system, `airstack doctor --live --stack lite_offload_global`. drawn as boundary crossings. Not committed yet — bootstrap via the wiring snapshot run (both entry points up) or `airstack doctor --snapshot` on a real bring-up (committed with an `unverified-in-CI` provenance line). -- Host placement is by convention (offboard = GCS machine, domain 0) until the - fleet configuration layer (`hosts:` maps, RFC #380 §2–3) lands. +- Host placement can now be declared instead of conventional: a fleet entry + with `stack: stacks/lite_offload_global` and `hosts: {offboard: gcs}` places + the offboard half on the named ground host (`airstack fleet generate` emits + its service with `AIRSTACK_STACK_ENTRY=offboard`; the robot gets `onboard`). + See `config/fleets/sim_three_mixed.yaml` (robot_3) and + [docs/development/fleets.md](../../docs/development/fleets.md). The manual + `--stack lite_offload_global:onboard|:offboard` form above remains for + single-machine runs. - `modules.repos` pins no external modules yet; `docker-compose.yaml` is a stub (trunk compose profiles provide the services). diff --git a/tests/conftest.py b/tests/conftest.py index 1a914a194..7aea0279d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -34,6 +34,13 @@ def pytest_addoption(parser): "None (legacy role dispatch). The wiring test " "drift-checks against stacks//wiring.md " "when set.") + parser.addoption("--fleet", default=None, + help="Fleet preset under config/fleets/ (RFC #380 §2), " + "e.g. sim_three_mixed. Sets FLEET_CONFIG_FILE for " + "airstack up and derives NUM_ROBOTS from the " + "fleet's robot count (overriding --num-robots). " + "Default: None (legacy --num-robots behavior, " + "unchanged).") parser.addoption("--stress-iterations", type=int, default=1, help="Number of up/down iterations per (sim, num_robots) config") parser.addoption("--stable-duration", type=int, default=120, @@ -186,6 +193,17 @@ def pytest_generate_tests(metafunc): return sims = [s.strip() for s in metafunc.config.getoption("--sim").split(",") if s.strip()] nums = [int(x) for x in metafunc.config.getoption("--num-robots").split(",") if x.strip()] + fleet = metafunc.config.getoption("--fleet") + if fleet: + # A fleet defines its own robot roster: campaigns run at exactly the + # fleet's robot count — the --num-robots matrix would otherwise spawn + # campaigns expecting robots the fleet never declares. + import os as _os + import yaml as _yaml + fleet_path = _os.path.join(AIRSTACK_ROOT, "config", "fleets", f"{fleet}.yaml") + with open(fleet_path, encoding="utf-8") as fh: + fleet_doc = _yaml.safe_load(fh) or {} + nums = [len(fleet_doc.get("robots") or {})] iterations = metafunc.config.getoption("--stress-iterations") params = [(s, n, i) for s in sims for n in nums for i in range(iterations)] ids = [f"{s}-{n}-iter{i}" for s, n, i in params] @@ -235,6 +253,27 @@ def airstack_env(request): env_overrides["AIRSTACK_STACK_DIR"] = f"/root/AirStack/stacks/{stack}" env_overrides["AIRSTACK_STACK_ENTRY"] = "stack" + # Fleet dispatch (RFC #380 §2): FLEET_CONFIG_FILE (container path) opts + # the run into fleet resolution; NUM_ROBOTS is derived from the fleet's + # robot count (overriding this parametrization's num_robots). `airstack + # up` sees the env var, validates the fleet, and auto-includes the + # generated per-robot compose when the fleet is heterogeneous. Isaac runs + # pin the generic fleet spawner explicitly (parametrized sim scripts in + # extra_env would otherwise shadow it). --fleet absent = byte-identical + # legacy behavior. + fleet = request.config.getoption("--fleet") + if fleet: + import yaml as _yaml + fleet_path = Path(AIRSTACK_ROOT) / "config" / "fleets" / f"{fleet}.yaml" + assert fleet_path.is_file(), f"--fleet {fleet}: no such file {fleet_path}" + with fleet_path.open(encoding="utf-8") as f: + fleet_robots = len((_yaml.safe_load(f) or {}).get("robots") or {}) + env_overrides["FLEET_CONFIG_FILE"] = f"/root/AirStack/config/fleets/{fleet}.yaml" + env_overrides["NUM_ROBOTS"] = str(fleet_robots) + num_robots = fleet_robots + if sim == "isaacsim": + env_overrides["ISAAC_SIM_SCRIPT_NAME"] = "fleet_spawn.py" + with logger_to(log): missing = missing_images(env=env_overrides) if missing: @@ -267,6 +306,8 @@ def airstack_env(request): "cfg": cfg, # None = legacy AUTONOMY_ROLE dispatch; else the stacks/ launched. "stack": stack, + # None = legacy NUM_ROBOTS behavior; else the config/fleets/ flown. + "fleet": fleet, } tid = current_test_id() diff --git a/tests/meta/test_fleet_contract.py b/tests/meta/test_fleet_contract.py new file mode 100644 index 000000000..4637f9448 --- /dev/null +++ b/tests/meta/test_fleet_contract.py @@ -0,0 +1,447 @@ +# Copyright (c) 2026 Carnegie Mellon University +# MIT License - see LICENSE in the repository root for full text. +"""Contract tests for fleets (RFC #380 §2, Phase P6). + +Pins the promises the fleet machinery makes: + +- **Resolver parity** — ``sim_one_default`` resolves ``robot_1`` to exactly + what the legacy ``robot_name_map`` resolver produces for + ``airstack-robot-desktop-1`` (same ROBOT_NAME / ROS_DOMAIN_ID): opting into + the fleet changes nothing for today's default checkout. +- **Generation** — ``fleet generate`` is deterministic (byte-identical + re-runs), detects homogeneity (writes nothing for replica-able fleets), and + emits the SPLIT placement: robot_3's ground host gets a service running the + same split stack with ``AIRSTACK_STACK_ENTRY=offboard``. +- **The trajectory hard-gate holds through fleet placement** — every split + stack the generated compose places passes ``gen_dds_router.py --check`` + (doctor hard gate #2: command authority stays onboard). +- **Launch intent** — ``airstack up --dry-run --fleet`` exports + FLEET_CONFIG_FILE + derived NUM_ROBOTS + the fleet spawner; explicit env + NUM_ROBOTS beats the fleet (banner); no fleet ⇒ no new effective-config + keys (byte-identical legacy contract). +- **Schema errors are named** — bad hosts/stack/robots produce errors naming + the offender. +- **Spawner mapping** — ``fleet_spawn.py``'s fleet→drone-config mapping is a + pure stdlib function (no Isaac import at module scope) and matches the + fleet file. +""" +import importlib.util +import os +import subprocess +import sys + +import pytest +import yaml + +from harness.discovery import repo_path + +pytestmark = pytest.mark.unit + +REPO = repo_path() +AIRSTACK = str(REPO / "airstack.sh") +RESOLVER = REPO / "tools" / "fleet" / "resolve_fleet.py" +GENERATOR = REPO / "tools" / "fleet" / "generate_fleet_compose.py" +GEN_DDS_ROUTER = REPO / "tools" / "gen_dds_router.py" +LEGACY_RESOLVER = REPO / "robot" / "docker" / "robot_name_map" / "resolve_robot_name.py" +LEGACY_MAP = REPO / "robot" / "docker" / "robot_name_map" / "default_robot_name_map.yaml" +FLEETS = REPO / "config" / "fleets" + +CONFIG_BEGIN = "--- effective launch config ---" +CONFIG_END = "--- end effective launch config ---" + + +def _load(path, name): + spec = importlib.util.spec_from_file_location(name, path) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +@pytest.fixture(scope="module") +def rf(): + return _load(RESOLVER, "airstack_resolve_fleet") + + +@pytest.fixture(scope="module") +def legacy(): + return _load(LEGACY_RESOLVER, "airstack_resolve_robot_name") + + +def run_tool(script, *args): + result = subprocess.run( + [sys.executable, str(script), *args], + capture_output=True, text=True, cwd=str(REPO), timeout=60, + ) + return result.returncode, result.stdout, result.stderr + + +def run_up_dry(*flags, env=None, check=True): + full_env = {**os.environ, **(env or {})} + result = subprocess.run( + [AIRSTACK, "up", "--dry-run", *flags], + capture_output=True, text=True, cwd=str(REPO), env=full_env, timeout=120, + ) + out = result.stdout + result.stderr + cfg = {} + in_cfg = False + for line in out.splitlines(): + line = line.strip() + if line == CONFIG_BEGIN: + in_cfg = True + continue + if line == CONFIG_END: + in_cfg = False + continue + if in_cfg and "=" in line: + key, _, value = line.partition("=") + cfg[key] = value + if check: + assert result.returncode == 0, f"dry-run failed unexpectedly:\n{out}" + assert cfg, f"no effective-config block in output:\n{out}" + return result.returncode, out, cfg + + +# ── resolver parity with the legacy robot_name_map ────────────────────────── + +def test_sim_one_default_matches_legacy_resolver(rf, legacy): + """Fleet resolution of today's default fleet == legacy resolver output for + the default single-robot container name.""" + legacy_name, legacy_domain = legacy.resolve_robot_name( + "airstack-robot-desktop-1", str(LEGACY_MAP) + ) + fleet = rf.load_fleet(FLEETS / "sim_one_default.yaml") + key = rf.resolve_identity(fleet, "airstack-robot-desktop-1") + resolved = rf.resolve_robot(fleet, REPO, key) + assert resolved["robot_name"] == legacy_name == "robot_1" + assert str(resolved["domain_id"]) == legacy_domain == "1" + + +def test_replica_indices_match_legacy_for_three_robots(rf, legacy): + fleet = rf.load_fleet(FLEETS / "sim_three_mixed.yaml") + for i in (1, 2, 3): + container = f"airstack-robot-desktop-{i}" + legacy_name, legacy_domain = legacy.resolve_robot_name(container, str(LEGACY_MAP)) + key = rf.resolve_identity(fleet, container) + resolved = rf.resolve_robot(fleet, REPO, key) + assert resolved["robot_name"] == legacy_name + assert str(resolved["domain_id"]) == legacy_domain + + +def test_resolver_exports_full_entry(rf): + """The exports carry the whole fleet entry, not just name+domain.""" + fleet = rf.load_fleet(FLEETS / "sim_three_mixed.yaml") + r3 = rf.resolve_robot(fleet, REPO, "robot_3") + assert r3["stack"] == "stacks/lite_offload_global" + assert r3["entry"] == "onboard" # hosts: ⇒ the robot runs the onboard half + assert r3["vehicle"] == "quad_default" + assert r3["urdf_file"].endswith("iris_with_sensors.pegasus.robot.urdf") + assert r3["hosts"] == {"offboard": "gcs"} + r2 = rf.resolve_robot(fleet, REPO, "robot_2") + assert r2["stack"] == "stacks/lite_default" + assert r2["entry"] == "stack" + + +# ── fleet generate: determinism, homogeneity, split placement ─────────────── + +@pytest.fixture(scope="module") +def gen(): + return _load(GENERATOR, "airstack_generate_fleet_compose") + + +def test_generate_is_deterministic(gen, rf): + fleet = rf.load_fleet(FLEETS / "sim_three_mixed.yaml") + first = gen.render(gen.build_compose(fleet, REPO, "config/fleets/sim_three_mixed.yaml")) + second = gen.render(gen.build_compose(fleet, REPO, "config/fleets/sim_three_mixed.yaml")) + assert first == second + assert "generated" in first.lower() + yaml.safe_load(first) # parses + + +def test_homogeneous_fleet_needs_no_generation(gen, rf): + fleet = rf.load_fleet(FLEETS / "sim_one_default.yaml") + assert rf.fleet_is_homogeneous(fleet, REPO) + code, out, err = run_tool(GENERATOR, str(FLEETS / "sim_one_default.yaml"), + "--project-root", str(REPO), "--check-homogeneous") + assert code == 0 and out.strip() == "homogeneous", err + code, out, _ = run_tool(GENERATOR, str(FLEETS / "sim_one_default.yaml"), + "--project-root", str(REPO)) + assert code == 0 + assert "NUM_ROBOTS=1" in out and "No generation needed" in out + + +def test_mixed_fleet_is_heterogeneous(gen, rf): + fleet = rf.load_fleet(FLEETS / "sim_three_mixed.yaml") + assert not rf.fleet_is_homogeneous(fleet, REPO) + + +def test_generated_split_placement(gen, rf): + """robot_3's onboard half + its ground host's offboard half, from one file.""" + fleet = rf.load_fleet(FLEETS / "sim_three_mixed.yaml") + compose = gen.build_compose(fleet, REPO, "config/fleets/sim_three_mixed.yaml") + services = compose["services"] + assert set(services) == {"robot_1", "robot_2", "robot_3", "gcs-robot_3"} + + def env_of(svc): + return dict(e.split("=", 1) for e in services[svc]["environment"] if "=" in e) + + onboard = env_of("robot_3") + assert onboard["AIRSTACK_STACK_DIR"] == "/root/AirStack/stacks/lite_offload_global" + assert onboard["AIRSTACK_STACK_ENTRY"] == "onboard" + assert onboard["ROBOT_NAME"] == "robot_3" + assert onboard["ROS_DOMAIN_ID"] == "3" + + offboard = env_of("gcs-robot_3") + assert offboard["AIRSTACK_STACK_DIR"] == "/root/AirStack/stacks/lite_offload_global" + assert offboard["AIRSTACK_STACK_ENTRY"] == "offboard" + assert offboard["ROBOT_NAME"] == "robot_3" # serves this tenant + assert offboard["ROS_DOMAIN_ID"] == "0" # the fleet's gcs_domain + assert offboard["LAUNCH_PACKAGE"] == "autonomy_bringup" + assert "ports" not in services["gcs-robot_3"] # mirrors legacy robot-offboard + + # every service is self-contained: explicit identity + fleet env + for name in services: + env = env_of(name) + assert env["FLEET_CONFIG_FILE"] == "/root/AirStack/config/fleets/sim_three_mixed.yaml" + assert "ROBOT_NAME" in env and "AIRSTACK_STACK_DIR" in env + assert "extends" not in services[name] + + +def test_split_stacks_placed_by_fleet_pass_bridge_hard_gate(gen, rf): + """Doctor hard gate #2 must hold for every split stack the generated + compose places: gen_dds_router.py --check exits 0 on its bridge.yaml.""" + fleet = rf.load_fleet(FLEETS / "sim_three_mixed.yaml") + compose = gen.build_compose(fleet, REPO, "config/fleets/sim_three_mixed.yaml") + split_stacks = set() + for svc in compose["services"].values(): + env = dict(e.split("=", 1) for e in svc["environment"] if "=" in e) + if env.get("AIRSTACK_STACK_ENTRY", "stack") != "stack": + split_stacks.add(env["AIRSTACK_STACK_DIR"].replace("/root/AirStack/", "")) + assert split_stacks == {"stacks/lite_offload_global"} + for stack_rel in split_stacks: + bridge = REPO / stack_rel / "bridge.yaml" + assert bridge.is_file(), f"split stack {stack_rel} has no bridge.yaml" + code, out, err = run_tool(GEN_DDS_ROUTER, str(bridge), "--check", + "--project-root", str(REPO)) + assert code == 0, f"bridge gate failed for {stack_rel}:\n{out}\n{err}" + + +# ── launch intent: --fleet dry-run exports + precedence ───────────────────── + +def test_dry_run_fleet_exports(): + code, out, cfg = run_up_dry("--fleet", "sim_one_default", "--sim", "isaac") + assert code == 0, out + assert cfg["FLEET_CONFIG_FILE"] == "/root/AirStack/config/fleets/sim_one_default.yaml" + assert cfg["NUM_ROBOTS"] == "1" + assert cfg["ISAAC_SIM_SCRIPT_NAME"] == "fleet_spawn.py" + assert "robot_1" in out # the resolved robot table prints + + +def test_dry_run_heterogeneous_fleet_swaps_profile_and_includes_compose(): + code, out, cfg = run_up_dry("--fleet", "sim_three_mixed", "--sim", "isaac") + assert code == 0, out + assert cfg["NUM_ROBOTS"] == "3" + profiles = cfg["COMPOSE_PROFILES"].split(",") + assert "fleet" in profiles and "desktop" not in profiles + assert "isaac-sim" in profiles + assert "docker-compose.fleet.yaml" in out + generated = REPO / ".airstack" / "generated" / "docker-compose.fleet.yaml" + assert generated.is_file() + assert yaml.safe_load(generated.read_text())["x-airstack-fleet"] == ( + "config/fleets/sim_three_mixed.yaml" + ) + + +def test_explicit_num_robots_beats_fleet_with_banner(): + code, out, cfg = run_up_dry("--fleet", "sim_one_default", "--sim", "isaac", + env={"NUM_ROBOTS": "5"}) + assert code == 0, out + assert cfg["NUM_ROBOTS"] == "5" + assert "OVERRIDE" in out and "NUM_ROBOTS=5" in out + + +def test_explicit_isaac_script_beats_fleet_spawner(): + _, out, cfg = run_up_dry( + "--fleet", "sim_one_default", "--sim", "isaac", + env={"ISAAC_SIM_SCRIPT_NAME": "my_custom_scene.py"}, + ) + assert cfg["ISAAC_SIM_SCRIPT_NAME"] == "my_custom_scene.py" + assert "OVERRIDE" in out + + +def test_fleet_and_robots_flags_are_mutually_exclusive(): + code, out, _ = run_up_dry("--fleet", "sim_one_default", "--robots", "2", + check=False) + assert code != 0 + assert "mutually exclusive" in out + + +def test_unknown_fleet_is_fatal_and_lists_available(): + code, out, _ = run_up_dry("--fleet", "no_such_fleet", check=False) + assert code != 0 + assert "no_such_fleet" in out + assert "sim_one_default" in out + + +def test_no_fleet_keeps_effective_config_key_free(): + """Byte-identical legacy contract: no fleet anywhere ⇒ no FLEET_CONFIG_FILE + key in the effective config at all (not even empty).""" + _, _, cfg = run_up_dry("--sim", "isaac", env={"FLEET_CONFIG_FILE": ""}) + assert "FLEET_CONFIG_FILE" not in cfg + + +def test_env_fleet_config_file_opts_in_like_the_flag(): + """The harness path: FLEET_CONFIG_FILE via env (container path) triggers + the same validation + derivation as --fleet.""" + code, out, cfg = run_up_dry( + "--sim", "isaac", + env={"FLEET_CONFIG_FILE": "/root/AirStack/config/fleets/sim_one_default.yaml"}, + ) + assert code == 0, out + assert cfg["FLEET_CONFIG_FILE"] == "/root/AirStack/config/fleets/sim_one_default.yaml" + assert cfg["NUM_ROBOTS"] == "1" + + +# ── schema errors are named ────────────────────────────────────────────────── + +def _write_fleet(tmp_path, body): + root = tmp_path / "checkout" + fleets = root / "config" / "fleets" + fleets.mkdir(parents=True) + path = fleets / "bad.yaml" + path.write_text(body, encoding="utf-8") + return path + + +def _validate(path): + return run_tool(RESOLVER, str(path), "--project-root", str(REPO), "--validate") + + +def test_error_hosts_naming_missing_ground(tmp_path): + path = _write_fleet(tmp_path, """ +defaults: {vehicle: quad_default, stack: stacks/full_default} +robots: + r1: {stack: stacks/lite_offload_global, hosts: {offboard: edge_box}} +""") + code, _, err = _validate(path) + assert code == 1 + assert "edge_box" in err and "ground" in err + + +def test_error_split_stack_without_hosts(tmp_path): + path = _write_fleet(tmp_path, """ +defaults: {vehicle: quad_default, stack: stacks/lite_offload_global} +robots: + r1: {} +""") + code, _, err = _validate(path) + assert code == 1 + assert "hosts" in err and "split" in err + + +def test_error_unknown_stack_named(tmp_path): + path = _write_fleet(tmp_path, """ +defaults: {vehicle: quad_default, stack: stacks/no_such_stack} +robots: + r1: {} +""") + code, _, err = _validate(path) + assert code == 1 + assert "no_such_stack" in err + + +def test_error_unknown_vehicle_named(tmp_path): + path = _write_fleet(tmp_path, """ +defaults: {vehicle: no_such_vehicle, stack: stacks/full_default} +robots: + r1: {} +""") + code, _, err = _validate(path) + assert code == 1 + assert "no_such_vehicle" in err + + +def test_error_empty_robots(tmp_path): + path = _write_fleet(tmp_path, "robots: {}\n") + code, _, err = _validate(path) + assert code == 1 + assert "robots" in err + + +def test_error_hosts_role_without_entry_point(tmp_path): + """A hosts role must match a launch entry file of the robot's stack.""" + path = _write_fleet(tmp_path, """ +defaults: {vehicle: quad_default, stack: stacks/lite_offload_global} +robots: + r1: {hosts: {edge_compute: gcs}} +ground: + gcs: {} +""") + code, _, err = _validate(path) + assert code == 1 + assert "edge_compute" in err and "launch/edge_compute.launch.xml" in err + + +def test_error_unknown_robot_key_named(tmp_path): + path = _write_fleet(tmp_path, """ +defaults: {vehicle: quad_default, stack: stacks/full_default} +robots: + r1: {vehicel: quad_default} +""") + code, _, err = _validate(path) + assert code == 1 + assert "vehicel" in err + + +def test_error_bad_domain_policy(tmp_path): + path = _write_fleet(tmp_path, """ +defaults: {vehicle: quad_default, stack: stacks/full_default} +robots: + r1: {} +network: {domain_policy: static} +""") + code, _, err = _validate(path) + assert code == 1 + assert "domain_policy" in err and "static" in err + + +# ── fleet spawner mapping (no Isaac needed) ────────────────────────────────── + +@pytest.fixture(scope="module") +def spawner(): + return _load( + REPO / "simulation" / "isaac-sim" / "launch_scripts" / "fleet_spawn.py", + "airstack_fleet_spawn", + ) + + +def test_fleet_spawn_imports_without_isaac(spawner): + """Module scope must stay stdlib+PyYAML (deferred-import contract).""" + assert callable(spawner.fleet_to_drone_configs) + + +def test_fleet_spawn_mapping_matches_fleet(spawner): + fleet = yaml.safe_load((FLEETS / "sim_three_mixed.yaml").read_text()) + cfgs = spawner.fleet_to_drone_configs(fleet, str(REPO)) + assert [c["domain_id"] for c in cfgs] == [1, 2, 3] + assert [c["robot_name"] for c in cfgs] == ["robot_1", "robot_2", "robot_3"] + assert [c["x_m"] for c in cfgs] == [-2.0, 0.0, 2.0] + assert all(c["z_m"] == 0.07 for c in cfgs) + assert all(c["lidar"] for c in cfgs) # quad_default carries a lidar_3d + + +def test_fleet_spawn_remaps_robot_container_path(spawner): + assert spawner.remap_fleet_path("/root/AirStack/config/fleets/f.yaml") == ( + "/isaac-sim/AirStack/config/fleets/f.yaml" + ) + assert spawner.remap_fleet_path("/elsewhere/f.yaml") == "/elsewhere/f.yaml" + + +def test_fleet_spawn_scene_resolution(spawner): + envs = {"Default Environment": "omniverse://default", "Curved Gridroom": "omniverse://curved"} + fleet = {"sim": {"scene": "default"}} + assert spawner.fleet_env_url(fleet, envs) == "omniverse://default" + assert spawner.fleet_env_url({}, envs) == "omniverse://default" + assert spawner.fleet_env_url({"sim": {"scene": "Curved Gridroom"}}, envs) == "omniverse://curved" + assert spawner.fleet_env_url({"sim": {"scene": "/scenes/x.usd"}}, envs) == "/scenes/x.usd" + with pytest.raises(ValueError): + spawner.fleet_env_url({"sim": {"scene": "nope"}}, envs) diff --git a/tests/meta/test_launch_intent_contract.py b/tests/meta/test_launch_intent_contract.py index f6c7ba153..afc7a4686 100644 --- a/tests/meta/test_launch_intent_contract.py +++ b/tests/meta/test_launch_intent_contract.py @@ -241,3 +241,33 @@ def test_stack_and_role_both_set_warns_stack_wins(): ) assert "stack wins" in out assert "legacy dispatch" not in out + + +# ── override-file golden equivalence (RFC #380 P6, deliverable 8) ─────────── +# overrides/*.env select sims/hardware, not topology — they must keep passing +# `up --dry-run` unchanged as the fleet/stack machinery lands on top of them. + +def test_override_ms_airsim_env_still_derives_expected_config(): + code, out, cfg = run_up_dry("--env-file", "overrides/ms-airsim.env") + assert code == 0, out + profiles = cfg["COMPOSE_PROFILES"].split(",") + assert "ms-airsim" in profiles and "desktop" in profiles + assert "isaac-sim" not in profiles + assert cfg["URDF_FILE"].endswith("iris_stereo.ms-airsim.urdf") + # sim/hardware override files never opt into fleets on their own + assert "FLEET_CONFIG_FILE" not in cfg + + +def test_override_l4t_px4_realrobot_env_still_derives_expected_config(): + code, out, cfg = run_up_dry( + "--env-file", "overrides/l4t-px4-realrobot.env", + # Scrub stack/fleet vars a developer shell might carry. + env={"AIRSTACK_STACK_DIR": "", "FLEET_CONFIG_FILE": ""}, + ) + assert code == 0, out + assert cfg["COMPOSE_PROFILES"] == "l4t" + assert cfg["NUM_ROBOTS"] == "1" + assert cfg["URDF_FILE"].endswith("iris_with_sensors.pegasus.robot.urdf") + assert "FLEET_CONFIG_FILE" not in cfg + # its explicit AUTONOMY_ROLE still draws (only) the deprecation courtesy + assert "legacy dispatch" in out diff --git a/tests/system/test_takeoff_hover_land.py b/tests/system/test_takeoff_hover_land.py index c560d31d3..bf2e29d29 100644 --- a/tests/system/test_takeoff_hover_land.py +++ b/tests/system/test_takeoff_hover_land.py @@ -373,7 +373,33 @@ def _run_parallel(num_robots, fn): list(ex.map(fn, range(1, num_robots + 1))) +def _wait_state_estimate_healthy(n, robot_container, cfg, budget_s=45): + """Wait for the safety monitor to report the state estimate healthy. + + px4_ready proves EKF/MAVROS signals, but the takeoff task server rejects + (or PX4 refuses arming for) goals sent before the drone_safety_monitor's + state-estimate watchdog has cleared — a race observed as 'Goal was + rejected' / 'failed to arm' right after slow sim loads. One message with + data: false = healthy. + """ + deadline = time.time() + budget_s + while time.time() < deadline: + result = ros2_exec( + robot_container, + f"timeout 8 ros2 topic echo --once " + f"/robot_{n}/behavior/drone_safety_monitor/state_estimate_timed_out " + f"2>/dev/null", + domain_id=n, setup_bash=cfg["robot_setup_bash"], timeout=20, + ) + if "data: false" in result.stdout: + return + time.sleep(2) + logger.warning("robot_%d: state-estimate watchdog not confirmed healthy " + "after %ds; sending takeoff anyway", n, budget_s) + + def _takeoff_one_robot(n, robot_container, cfg, velocity): + _wait_state_estimate_healthy(n, robot_container, cfg) timeout = _phase_timeout(velocity) target = TARGET_ALTITUDE_M streams = _start_captures(robot_container, cfg["robot_setup_bash"], diff --git a/tools/doctor/checks.py b/tools/doctor/checks.py index 7bccfb9a8..25f93108c 100644 --- a/tools/doctor/checks.py +++ b/tools/doctor/checks.py @@ -317,9 +317,14 @@ def _docker(args, timeout=60, check=True): def discover_robot_containers(): - """Running robot containers (compose replicas), sorted (as ready.sh does).""" + """Running robot containers, sorted (mirrors ready.sh): compose replicas + (airstack-robot-desktop-N) and fleet-generated services + (airstack-robot_N-1); ground-host tenants (gcs-robot_N) are not robots.""" out = _docker(["ps", "--format", "{{.Names}}"]) - return sorted(n for n in out.splitlines() if "-robot-" in n) + return sorted( + n for n in out.splitlines() + if ("-robot-" in n or "-robot_" in n) and "gcs-" not in n + ) def container_identity(container): @@ -381,15 +386,40 @@ def _batched_topic_info(container, domain, topics, ws): return outputs -def capture_live_graph(log=print): - """Snapshot every running robot's graph and merge into one normalized - graph — the same capture tests/system/test_wiring_snapshot.py performs, - driven by plain `docker exec` so it runs on any host with the stack up.""" +def container_stack_dir(container): + """The container's AIRSTACK_STACK_DIR ('' when legacy role dispatch).""" + try: + out = _docker(["exec", container, "printenv", "AIRSTACK_STACK_DIR"]) + return out.strip() + except Exception: + return "" + + +def capture_live_graph(log=print, stack=None): + """Snapshot running robots' graphs and merge into one normalized graph — + the same capture tests/system/test_wiring_snapshot.py performs, driven by + plain `docker exec` so it runs on any host with the stack up. + + In a heterogeneous fleet, robots run different stacks; when ``stack`` is + given, only containers whose AIRSTACK_STACK_DIR names that stack are + captured — one wiring.md describes one stack, not the fleet union.""" ws = _wiring_snapshot() containers = discover_robot_containers() if not containers: raise RuntimeError("no running robot containers (docker ps shows no " - "'-robot-' names) — bring the stack up first") + "robot names) — bring the stack up first") + if stack: + matched = [c for c in containers + if container_stack_dir(c).rstrip("/").endswith(f"/{stack}")] + skipped = [c for c in containers if c not in matched] + if skipped: + log(f"[doctor] skipping {len(skipped)} robot container(s) on other " + f"stacks: {', '.join(skipped)}") + if not matched: + raise RuntimeError( + f"no running robot container has AIRSTACK_STACK_DIR ending in " + f"/{stack} — is that stack actually up?") + containers = matched nodes, topics, edges = set(), {}, [] for container in containers: robot_name, domain = container_identity(container) @@ -492,7 +522,7 @@ def run_live(root, stack, strict=False, log=print): "AIRSTACK_STACK_DIR (airstack up ...stack )") return 1 - graph = capture_live_graph(log=log) + graph = capture_live_graph(log=log, stack=stack) exit_code = 0 safety = check_safety_floor(graph) @@ -537,7 +567,7 @@ def run_snapshot(root, stack, log=print): log(f"[doctor] no stack folder at {stack_dir}") return 1 - graph = capture_live_graph(log=log) + graph = capture_live_graph(log=log, stack=stack) safety = check_safety_floor(graph) for message in safety.messages: log(f"[doctor] {message}") diff --git a/tools/fleet/generate_fleet_compose.py b/tools/fleet/generate_fleet_compose.py new file mode 100755 index 000000000..c1e4e2bcd --- /dev/null +++ b/tools/fleet/generate_fleet_compose.py @@ -0,0 +1,289 @@ +#!/usr/bin/env python3 +# Copyright (c) 2026 Carnegie Mellon University +# MIT License - see LICENSE in the repository root for full text. +"""Generate per-robot compose services for a HETEROGENEOUS fleet (RFC #380 §2). + +``deploy.replicas`` can only stamp identical containers, so a fleet whose +robots differ (stack, vehicle, or split placement) needs one compose service +per robot — plus one per (ground host × offboard tenant). This tool writes +them to ``.airstack/generated/docker-compose.fleet.yaml`` (gitignored, +machine-local, regenerated on demand), which ``airstack up --fleet `` +includes automatically. + +Homogeneous fleets need NO generation: the tool detects homogeneity, says so, +and writes nothing (deploy.replicas + the fleet resolver handle them). + +Service shape: self-contained definitions — extending nothing — that copy +robot-desktop's essentials (image, command, network, GPU reservation, mounts) +with **explicit per-robot env**: ROBOT_NAME, ROS_DOMAIN_ID, AIRSTACK_STACK_DIR, +AIRSTACK_STACK_ENTRY, FLEET_CONFIG_FILE (plus VEHICLE / CALIBRATION_DIR). +Explicit ROBOT_NAME means the container's .bashrc skips name resolution +entirely (pre-set env wins — the documented contract). + +SPLIT placement: a robot with ``hosts: {offboard: }`` gets its +``onboard`` entry; the named ground host gets one service per tenant robot +running the SAME stack with ``AIRSTACK_STACK_ENTRY=offboard`` on the fleet's +``gcs_domain`` (mirroring the legacy robot-offboard service). + +Output is deterministic: a pure function of the fleet file + checkout layout +(no timestamps; host paths are absolute on purpose — relative bind sources +resolve against ambiguous bases when compose merges ``-f`` files, the same +rule ``tools/module_overlay.py`` follows). + +CLI:: + + generate_fleet_compose.py [--project-root DIR] [--out PATH] + [--check-homogeneous] + +Exit 0 always on success; ``--check-homogeneous`` prints ``homogeneous`` or +``heterogeneous`` and writes nothing. +""" +import argparse +import sys +from pathlib import Path + +import yaml + +_TOOLS_FLEET_DIR = Path(__file__).resolve().parent +if str(_TOOLS_FLEET_DIR) not in sys.path: + sys.path.insert(0, str(_TOOLS_FLEET_DIR)) + +from resolve_fleet import ( # noqa: E402 + FleetError, + fleet_is_homogeneous, + load_fleet, + project_root_of, + resolve_fleet, + validate_fleet, +) + +GENERATED_REL = Path(".airstack/generated/docker-compose.fleet.yaml") +CONTAINER_ROOT = "/root/AirStack" + +HEADER = """\ +# GENERATED by tools/fleet/generate_fleet_compose.py — DO NOT EDIT (RFC #380 §2). +# Per-robot services for a heterogeneous fleet: deploy.replicas can only stamp +# identical containers, so each robot (and each ground host's offboard tenant) +# gets its own self-contained service with explicit identity/placement env. +# Regenerate: airstack fleet generate (airstack up --fleet +# regenerates and includes this file automatically; profile: fleet). +# Host paths are absolute on purpose: relative bind sources resolve against +# ambiguous bases when compose merges -f files (same rule as the module +# overlay compose). +""" + +# The robot-desktop bring-up command, copied verbatim (docker-compose.yaml is +# the source of truth; the AUTONOMY_ROLE arg is ignored whenever a stack dir +# is set — every fleet service sets one). +ROBOT_COMMAND = ( + "bash -c \" " + "if [ -z \\\"$$DISPLAY\\\" ] && command -v Xvfb >/dev/null 2>&1; then " + "tmux new -d -s xvfb 'Xvfb :99 -screen 0 1280x720x24 -ac +extension GLX +render -noreset 2>&1 | tee /tmp/xvfb.log'; " + "export DISPLAY=:99; " + "for i in 1 2 3 4 5 6 7 8 9 10; do [ -e /tmp/.X11-unix/X99 ] && break; sleep 1; done; " + "fi; " + "service ssh restart; " + "tmux new -d -s bringup; " + "if [ $$AUTOLAUNCH == 'true' ]; then " + "tmux send-keys -t bringup:0.0 'bws && sws && ros2 launch $$LAUNCH_PACKAGE robot.launch.xml role:=$$AUTONOMY_ROLE' ENTER; " + "fi; " + "sleep infinity\"" +) + + +def _base_volumes(root): + """robot_base's bind mounts (robot/docker/robot-base-docker-compose.yaml), + with host sides made absolute.""" + r = str(root) + return [ + "$HOME/.Xauthority:/.Xauthority", + "/tmp/.X11-unix:/tmp/.X11-unix", + f"{r}/robot/docker/.dev:/root/.dev:rw", + f"{r}/common/.bash_profile:/root/.bash_profile:rw", + f"{r}/robot/docker/.bashrc:/root/.bashrc:rw", + f"{r}/common/inputrc:/etc/inputrc:rw", + f"{r}/common/.tmux.conf:/root/.tmux.conf:rw", + f"{r}/robot/docker/robot_name_map:/root/AirStack/robot/docker/robot_name_map:rw", + f"{r}/common/ros_packages:/root/AirStack/robot/ros_ws/src/common:rw", + f"{r}/common/fastdds.xml:/root/AirStack/robot/ros_ws/src/fastdds.xml", + f"{r}/robot/ros_ws:/root/AirStack/robot/ros_ws:rw", + f"{r}/stacks:/root/AirStack/stacks:rw", + f"{r}/robot/bags:/bags:rw", + # fleet + vehicle configs (FLEET_CONFIG_FILE, CALIBRATION_DIR) + f"{r}/config:/root/AirStack/config:ro", + f"{r}/tools/fleet:/root/AirStack/tools/fleet:ro", + # generated artifacts split-stack entries read at launch (bridge-derived + # DDS-router configs) + f"{r}/.airstack/generated:/root/AirStack/.airstack/generated:ro", + ] + + +def _common_environment(): + """Env shared by every fleet service (robot_base + robot-desktop essentials, + interpolated from .env at compose time exactly like the originals).""" + return [ + "DISPLAY=${DISPLAY}", + "QT_X11_NO_MITSHM=1", + "QT_QPA_PLATFORM", + "RECORD_BAGS=${RECORD_BAGS}", + "LOG_CONFIG=${LOG_CONFIG:-log.yaml}", + "URDF_FILE=${URDF_FILE}", + "OFFBOARD_BASE_PORT=${OFFBOARD_BASE_PORT}", + "ONBOARD_BASE_PORT=${ONBOARD_BASE_PORT}", + "ROBOT_NAME_MAP_CONFIG_FILE=${ROBOT_NAME_MAP_CONFIG_FILE:-default_robot_name_map.yaml}", + "DEBUG_RVIZ=${DEBUG_RVIZ:-false}", + "AUTOLAUNCH=${AUTOLAUNCH:-true}", + "NVIDIA_DRIVER_CAPABILITIES=all", + "SIM_IP=${SIM_IP:-172.31.0.200}", + # role is ignored whenever a stack dir is set (robot.launch.xml); kept + # non-empty so the shared bring-up command's role:=$AUTONOMY_ROLE arg + # stays well-formed. + "AUTONOMY_ROLE=full", + ] + + +def _identity_environment(robot_name, domain_id, stack_rel, entry, + fleet_container_path, vehicle, calibration_rel): + return [ + f"ROBOT_NAME={robot_name}", + f"ROS_DOMAIN_ID={domain_id}", + f"AIRSTACK_STACK_DIR={CONTAINER_ROOT}/{stack_rel}", + f"AIRSTACK_STACK_ENTRY={entry}", + f"FLEET_CONFIG_FILE={fleet_container_path}", + f"VEHICLE={vehicle}", + "CALIBRATION_DIR=" + ( + f"{CONTAINER_ROOT}/{calibration_rel}" if calibration_rel else "" + ), + ] + + +def _service_skeleton(root): + return { + "profiles": ["fleet"], + "image": "${PROJECT_DOCKER_REGISTRY}/${PROJECT_NAME}:v${VERSION}_robot-x86-64_${DOCKER_IMAGE_BUILD_MODE}", + "stdin_open": True, + "tty": True, + "privileged": True, + "command": ROBOT_COMMAND, + "networks": ["airstack_network"], + "volumes": _base_volumes(root), + "deploy": { + "resources": { + "reservations": { + "devices": [ + {"driver": "nvidia", "count": 1, "capabilities": ["gpu"]} + ] + } + } + }, + } + + +def build_compose(fleet, root, fleet_rel): + """Compose dict for a heterogeneous fleet. Raises FleetError on problems.""" + resolved = resolve_fleet(fleet, root) + fleet_container_path = f"{CONTAINER_ROOT}/{fleet_rel}" + services = {} + + for robot in resolved["robots"]: + svc = _service_skeleton(root) + svc["environment"] = _common_environment() + [ + "LAUNCH_PACKAGE=desktop_bringup", # desktop parity: adds RViz + ] + _identity_environment( + robot["robot_name"], robot["domain_id"], robot["stack"], + robot["entry"], fleet_container_path, robot["vehicle"], + robot["calibration_dir"], + ) + # Same host port ranges as robot-desktop: docker binds the first free + # port in the range per container. + svc["ports"] = ["2223-2243:22", "8767-8787:8765"] + services[robot["robot_name"]] = svc + + gcs_domain = resolved["network"]["gcs_domain"] + for host, cfg in resolved["ground"].items(): + for tenant in cfg["tenants"]: + svc = _service_skeleton(root) + # Mirrors the legacy robot-offboard service: no ssh/foxglove ports, + # autonomy_bringup (no per-robot RViz), gcs domain, offboard entry + # of the SAME split stack, serving one tenant robot. + svc["environment"] = _common_environment() + [ + "LAUNCH_PACKAGE=autonomy_bringup", + ] + _identity_environment( + tenant["robot_name"], gcs_domain, tenant["stack"], + tenant["role"], fleet_container_path, "-", "", + ) + # VEHICLE is meaningless on a ground host; drop the placeholder. + svc["environment"] = [ + e for e in svc["environment"] if e != "VEHICLE=-" + ] + services[f"{host}-{tenant['robot_name']}"] = svc + + return { + "x-airstack-fleet": fleet_rel, + "services": services, + } + + +def render(compose): + return HEADER + yaml.safe_dump( + compose, sort_keys=True, default_flow_style=False, width=100 + ) + + +def main(argv=None): + parser = argparse.ArgumentParser( + description="Generate per-robot compose services for a heterogeneous " + "fleet (RFC #380 §2)." + ) + parser.add_argument("fleet_file", help="path to config/fleets/.yaml") + parser.add_argument("--project-root", default=None) + parser.add_argument("--out", default=None, + help="output path (default: /.airstack/generated/" + "docker-compose.fleet.yaml)") + parser.add_argument("--check-homogeneous", action="store_true", + help="print 'homogeneous' or 'heterogeneous'; write nothing") + args = parser.parse_args(argv) + + root = Path(args.project_root) if args.project_root else project_root_of(args.fleet_file) + fleet_path = Path(args.fleet_file).resolve() + try: + fleet_rel = str(fleet_path.relative_to(root.resolve())) + except ValueError: + fleet_rel = f"config/fleets/{fleet_path.name}" + + try: + fleet = load_fleet(fleet_path) + errors = validate_fleet(fleet, root) + if errors: + for err in errors: + print(f"Error: {err}", file=sys.stderr) + return 1 + + homogeneous = fleet_is_homogeneous(fleet, root) + if args.check_homogeneous: + print("homogeneous" if homogeneous else "heterogeneous") + return 0 + if homogeneous: + n = len(fleet["robots"]) + print( + f"Fleet '{fleet_path.stem}' is homogeneous ({n} identical robot(s)) — " + f"deploy.replicas handles it: use NUM_ROBOTS={n} " + f"(airstack up --fleet {fleet_path.stem} derives it). " + f"No generation needed." + ) + return 0 + + out = Path(args.out) if args.out else root / GENERATED_REL + out.parent.mkdir(parents=True, exist_ok=True) + content = render(build_compose(fleet, root, fleet_rel)) + out.write_text(content, encoding="utf-8") + n_services = len(build_compose(fleet, root, fleet_rel)["services"]) + print(f"Wrote {out} ({n_services} service(s), profile 'fleet').") + return 0 + except FleetError as exc: + print(f"Error: {exc}", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/fleet/resolve_fleet.py b/tools/fleet/resolve_fleet.py new file mode 100755 index 000000000..56f324ad2 --- /dev/null +++ b/tools/fleet/resolve_fleet.py @@ -0,0 +1,472 @@ +#!/usr/bin/env python3 +# Copyright (c) 2026 Carnegie Mellon University +# MIT License - see LICENSE in the repository root for full text. +"""Resolve a robot's whole fleet entry from a fleet file (RFC #380 §2). + +The fleet-file successor of ``robot/docker/robot_name_map/resolve_robot_name.py``: +where the legacy resolver maps a container/host name to ``ROBOT_NAME`` + +``ROS_DOMAIN_ID`` only, this one resolves the **whole fleet entry** — name, +domain, stack placement (dir + entry point), vehicle, URDF, and per-unit +calibration overlay — from a ``config/fleets/*.yaml`` file. + +Opt-in: the robot container's ``.bashrc`` calls this ONLY when +``FLEET_CONFIG_FILE`` is set (``airstack up --fleet ``); otherwise the +legacy resolver runs and behavior is byte-identical to before. + +Usage (mirrors resolve_robot_name.py's eval-able stdout contract):: + + resolve_fleet.py --name airstack-robot-desktop-2 # exports + resolve_fleet.py --index 2 # exports + resolve_fleet.py --robot robot_2 # exports + resolve_fleet.py --validate # whole-fleet schema check + resolve_fleet.py --json # full resolved fleet (machine) + resolve_fleet.py --table # resolved robot table (human) + +Export mode prints ``NAME=value`` lines (eval'd by ``robot/docker/.bashrc``):: + + ROBOT_NAME=robot_1 + ROS_DOMAIN_ID=1 + AIRSTACK_STACK_DIR=/root/AirStack/stacks/full_default + AIRSTACK_STACK_ENTRY=stack + URDF_FILE=robot_descriptions/iris/urdf/iris_with_sensors.pegasus.robot.urdf + VEHICLE=quad_default + CALIBRATION_DIR= + +Identity resolution for ``--name`` (top-down, first match wins): + 1. exact robot key match (``--name wanda`` → robot ``wanda``) + 2. trailing replica/host index (``airstack-robot-desktop-2`` / ``robot-2`` + → the 2nd robot in file order) — same convention as the legacy + ``default_robot_name_map.yaml`` rule ``.*robot-.*(\\d+)``. + +``network.domain_policy: auto`` (the only policy implemented) assigns robot N +(1-based file order) → domain N — today's rule, so a fleet whose robots are +named ``robot_1..robot_N`` resolves identically to the legacy resolver. + +All resolution is stdlib + PyYAML; paths in exports are rooted at the fleet +file's checkout root (``/config/fleets/.yaml`` → ````), which is +``/root/AirStack`` inside robot containers and the checkout on the host. +""" +import argparse +import json +import re +import sys +from pathlib import Path + +import yaml + +DEFAULT_ENTRY = "stack" +ONBOARD_ENTRY = "onboard" +SPAWN_DEFAULT = [0.0, 0.0, 0.07] + +# Robot-level keys the schema accepts. Unknown keys are named errors so typos +# (``vehicel:``) fail loudly instead of silently applying defaults. +ROBOT_KEYS = {"vehicle", "unit", "stack", "spawn", "hosts", "overrides"} +GROUND_KEYS = {"stack"} +FLEET_KEYS = {"defaults", "robots", "ground", "sim", "network"} +NETWORK_KEYS = {"domain_policy", "gossip_domain", "gcs_domain"} + +_TRAILING_INDEX_RE = re.compile(r"(\d+)$") + + +class FleetError(Exception): + """A named fleet-file problem (schema or resolution).""" + + +# ── loading ────────────────────────────────────────────────────────────────── + +def project_root_of(fleet_path): + """Checkout root for a fleet file at ``/config/fleets/.yaml``. + + Falls back to the file's grandparent's parent regardless of naming, so a + fleet file elsewhere still resolves relative to a sensible root. + """ + return Path(fleet_path).resolve().parents[2] + + +def load_fleet(fleet_path): + path = Path(fleet_path) + if not path.is_file(): + raise FleetError(f"fleet file not found: {path}") + try: + with path.open(encoding="utf-8") as f: + data = yaml.safe_load(f) + except yaml.YAMLError as exc: + raise FleetError(f"fleet file is not valid YAML: {path}: {exc}") from exc + if not isinstance(data, dict): + raise FleetError(f"fleet file must be a YAML mapping: {path}") + return data + + +def load_vehicle(root, name): + """Load ``config/vehicles//vehicle.yaml`` under ``root``.""" + manifest = Path(root) / "config" / "vehicles" / name / "vehicle.yaml" + if not manifest.is_file(): + raise FleetError( + f"vehicle '{name}' has no manifest at config/vehicles/{name}/vehicle.yaml" + ) + with manifest.open(encoding="utf-8") as f: + data = yaml.safe_load(f) or {} + if not isinstance(data, dict): + raise FleetError(f"vehicle manifest must be a YAML mapping: {manifest}") + return data + + +# ── resolution helpers ─────────────────────────────────────────────────────── + +def _robots(fleet): + robots = fleet.get("robots") + if not isinstance(robots, dict) or not robots: + raise FleetError("fleet has no robots: — 'robots:' must be a non-empty mapping") + return list(robots.items()) + + +def resolve_stack_path(root, stack_ref): + """Resolve a fleet ``stack:`` value to a checkout-relative stack dir. + + Order (RFC #380 §3): a path in the checkout first (``stacks/``), + then ``/`` against external stack repos fetched by + ``airstack sync`` into ``stacks/.external//``. + """ + root = Path(root) + if not isinstance(stack_ref, str) or not stack_ref: + raise FleetError(f"stack reference must be a non-empty string (got {stack_ref!r})") + if (root / stack_ref).is_dir(): + return stack_ref + if "/" in stack_ref and not stack_ref.startswith("stacks/"): + alias, _, name = stack_ref.partition("/") + external = Path("stacks") / ".external" / alias / name + if (root / external).is_dir(): + return str(external) + raise FleetError( + f"stack '{stack_ref}' not found: no {stack_ref} in the checkout and no " + f"external checkout at {external} — declare the repo under 'stacks:' in " + f"airstack.yaml and run 'airstack sync'" + ) + raise FleetError(f"stack '{stack_ref}' not found under {root}") + + +def stack_entries(root, stack_rel): + launch_dir = Path(root) / stack_rel / "launch" + if not launch_dir.is_dir(): + raise FleetError(f"stack '{stack_rel}' has no launch/ directory") + return sorted( + p.name[: -len(".launch.xml")] + for p in launch_dir.glob("*.launch.xml") + ) + + +def _domain_policy(fleet): + network = fleet.get("network") or {} + if not isinstance(network, dict): + raise FleetError("'network:' must be a mapping") + policy = network.get("domain_policy", "auto") + if policy != "auto": + raise FleetError( + f"network.domain_policy '{policy}' is not implemented — only 'auto' " + f"(robot N → domain N) is" + ) + return policy + + +def resolve_robot(fleet, root, key): + """Resolve one robot's full entry. Returns a plain dict (JSON-safe).""" + robots = _robots(fleet) + keys = [k for k, _ in robots] + if key not in keys: + raise FleetError(f"no robot '{key}' in fleet (robots: {', '.join(keys)})") + index = keys.index(key) + 1 + entry = dict(robots[index - 1][1] or {}) + unknown = set(entry) - ROBOT_KEYS + if unknown: + raise FleetError( + f"robot '{key}' has unknown key(s): {', '.join(sorted(unknown))} " + f"(allowed: {', '.join(sorted(ROBOT_KEYS))})" + ) + + defaults = fleet.get("defaults") or {} + _domain_policy(fleet) # auto: robot N → domain N + + vehicle = entry.get("vehicle", defaults.get("vehicle")) + if not vehicle: + raise FleetError(f"robot '{key}' has no vehicle and the fleet declares no defaults.vehicle") + vehicle_manifest = load_vehicle(root, vehicle) + urdf = ((vehicle_manifest.get("airframe") or {}).get("base_urdf")) or "" + if not urdf: + raise FleetError(f"vehicle '{vehicle}' declares no airframe.base_urdf") + + stack_ref = entry.get("stack", defaults.get("stack")) + if not stack_ref: + raise FleetError(f"robot '{key}' has no stack and the fleet declares no defaults.stack") + stack_rel = resolve_stack_path(root, stack_ref) + entries = stack_entries(root, stack_rel) + + hosts = entry.get("hosts") or {} + if hosts and not isinstance(hosts, dict): + raise FleetError(f"robot '{key}': 'hosts:' must be a mapping of role → ground host") + ground = fleet.get("ground") or {} + if hosts: + if ONBOARD_ENTRY not in entries: + raise FleetError( + f"robot '{key}' names hosts: but stack '{stack_ref}' has no " + f"launch/onboard.launch.xml entry point (entries: {', '.join(entries)})" + ) + for role, host in hosts.items(): + if role == ONBOARD_ENTRY: + raise FleetError( + f"robot '{key}': hosts role 'onboard' is the robot itself — " + f"name only offboard roles" + ) + if role not in entries: + raise FleetError( + f"robot '{key}': hosts role '{role}' has no matching entry point " + f"launch/{role}.launch.xml in stack '{stack_ref}' " + f"(entries: {', '.join(entries)})" + ) + if host not in ground: + raise FleetError( + f"robot '{key}': hosts.{role} names ground host '{host}' but the " + f"fleet declares no ground.{host} entry " + f"(ground hosts: {', '.join(ground) or ''})" + ) + launch_entry = ONBOARD_ENTRY + else: + if DEFAULT_ENTRY not in entries: + raise FleetError( + f"robot '{key}': stack '{stack_ref}' is a split stack " + f"(entries: {', '.join(entries)}) — a robot using it must declare " + f"hosts: {{: }} placement" + ) + launch_entry = DEFAULT_ENTRY + + spawn = entry.get("spawn", SPAWN_DEFAULT) + if (not isinstance(spawn, (list, tuple)) or len(spawn) != 3 + or not all(isinstance(v, (int, float)) for v in spawn)): + raise FleetError(f"robot '{key}': spawn must be [x, y, z] numbers (got {spawn!r})") + + unit = entry.get("unit") + calibration_rel = f"config/local/calibration/{unit}" if unit else "" + + overrides = entry.get("overrides") or {} + if overrides and not isinstance(overrides, dict): + raise FleetError(f"robot '{key}': 'overrides:' must be a mapping of leaf values") + + return { + "robot_name": key, + "index": index, + "domain_id": index, # domain_policy auto: robot N → domain N + "vehicle": vehicle, + "urdf_file": urdf, + "stack": stack_rel, + "stack_ref": stack_ref, + "entry": launch_entry, + "hosts": dict(hosts), + "spawn": [float(v) for v in spawn], + "unit": unit, + "calibration_dir": calibration_rel, + "overrides": overrides, + "lidar": vehicle_has_lidar(vehicle_manifest), + } + + +def vehicle_has_lidar(vehicle_manifest): + """True when the vehicle's sensor list carries any lidar entry.""" + for sensor in vehicle_manifest.get("sensors") or []: + if isinstance(sensor, dict) and "lidar" in str(sensor.get("type", "")): + return True + return False + + +def resolve_fleet(fleet, root): + """Resolve every robot + ground host. Returns the full machine-readable view.""" + robots = [resolve_robot(fleet, root, key) for key, _ in _robots(fleet)] + ground = {} + for host, cfg in (fleet.get("ground") or {}).items(): + cfg = cfg or {} + unknown = set(cfg) - GROUND_KEYS + if unknown: + raise FleetError( + f"ground host '{host}' has unknown key(s): {', '.join(sorted(unknown))}" + ) + tenants = [ + {"robot_name": r["robot_name"], "role": role, "stack": r["stack"], + "domain_id": r["domain_id"]} + for r in robots + for role, h in r["hosts"].items() + if h == host + ] + ground[host] = {"stack": cfg.get("stack"), "tenants": tenants} + network = fleet.get("network") or {} + return { + "robots": robots, + "ground": ground, + "sim": fleet.get("sim") or {}, + "network": { + "domain_policy": network.get("domain_policy", "auto"), + "gossip_domain": network.get("gossip_domain", 99), + "gcs_domain": network.get("gcs_domain", 0), + }, + } + + +def fleet_is_homogeneous(fleet, root): + """True when deploy.replicas can stamp this fleet: every robot runs the same + vehicle and stack, none has hosts: placement, and there are no ground hosts.""" + resolved = resolve_fleet(fleet, root) + robots = resolved["robots"] + if resolved["ground"]: + return False + first = robots[0] + return all( + r["vehicle"] == first["vehicle"] + and r["stack"] == first["stack"] + and not r["hosts"] + for r in robots + ) + + +def validate_fleet(fleet, root): + """Return a list of named error strings (empty = valid).""" + errors = [] + unknown = set(fleet) - FLEET_KEYS + if unknown: + errors.append( + f"unknown top-level key(s): {', '.join(sorted(unknown))} " + f"(allowed: {', '.join(sorted(FLEET_KEYS))})" + ) + network = fleet.get("network") or {} + if isinstance(network, dict): + unknown_net = set(network) - NETWORK_KEYS + if unknown_net: + errors.append(f"unknown network key(s): {', '.join(sorted(unknown_net))}") + try: + resolve_fleet(fleet, root) + except FleetError as exc: + errors.append(str(exc)) + return errors + + +def resolve_identity(fleet, name): + """Map a container/host name to a robot key (see module docstring).""" + keys = [k for k, _ in _robots(fleet)] + if name in keys: + return name + match = _TRAILING_INDEX_RE.search(name) + if match: + index = int(match.group(1)) + if 1 <= index <= len(keys): + return keys[index - 1] + raise FleetError( + f"'{name}' resolves to index {index} but the fleet has only " + f"{len(keys)} robot(s)" + ) + raise FleetError( + f"no robot identity for '{name}': not a robot key " + f"({', '.join(keys)}) and no trailing index" + ) + + +# ── output modes ───────────────────────────────────────────────────────────── + +def print_exports(resolved, root): + root = Path(root) + stack_dir = str(root / resolved["stack"]) + cal = str(root / resolved["calibration_dir"]) if resolved["calibration_dir"] else "" + print(f"ROBOT_NAME={resolved['robot_name']}") + print(f"ROS_DOMAIN_ID={resolved['domain_id']}") + print(f"AIRSTACK_STACK_DIR={stack_dir}") + print(f"AIRSTACK_STACK_ENTRY={resolved['entry']}") + print(f"URDF_FILE={resolved['urdf_file']}") + print(f"VEHICLE={resolved['vehicle']}") + print(f"CALIBRATION_DIR={cal}") + + +def print_table(resolved_fleet): + rows = [ + ( + r["robot_name"], str(r["domain_id"]), r["vehicle"], r["stack_ref"], + r["entry"], + ",".join(f"{role}:{host}" for role, host in r["hosts"].items()) or "-", + "[" + ", ".join(f"{v:g}" for v in r["spawn"]) + "]", + ) + for r in resolved_fleet["robots"] + ] + for host, cfg in resolved_fleet["ground"].items(): + for tenant in cfg["tenants"]: + rows.append(( + f"{host} (ground)", str(resolved_fleet["network"]["gcs_domain"]), + "-", tenant["stack"], tenant["role"], + f"serves:{tenant['robot_name']}", "-", + )) + headers = ("ROBOT", "DOMAIN", "VEHICLE", "STACK", "ENTRY", "HOSTS", "SPAWN") + widths = [max(len(r[i]) for r in rows + [headers]) for i in range(len(headers))] + fmt = " ".join("{:<%d}" % w for w in widths) + print(fmt.format(*headers)) + for row in rows: + print(fmt.format(*row)) + + +def main(argv=None): + parser = argparse.ArgumentParser( + description="Resolve robot identity/placement from a fleet file (RFC #380 §2)." + ) + parser.add_argument("fleet_file", help="path to config/fleets/.yaml") + parser.add_argument("--project-root", default=None, + help="checkout root (default: derived from the fleet file " + "path — /config/fleets/.yaml)") + who = parser.add_mutually_exclusive_group() + who.add_argument("--name", help="container or host name to resolve") + who.add_argument("--index", type=int, help="1-based robot index to resolve") + who.add_argument("--robot", help="explicit robot key to resolve") + mode = parser.add_mutually_exclusive_group() + mode.add_argument("--validate", action="store_true", + help="validate the whole fleet; named errors, exit 1 on any") + mode.add_argument("--json", action="store_true", + help="dump the fully resolved fleet as JSON") + mode.add_argument("--table", action="store_true", + help="print the resolved robot table (human)") + args = parser.parse_args(argv) + + root = Path(args.project_root) if args.project_root else project_root_of(args.fleet_file) + + try: + fleet = load_fleet(args.fleet_file) + if args.validate: + errors = validate_fleet(fleet, root) + if errors: + for err in errors: + print(f"Error: {err}", file=sys.stderr) + return 1 + n = len(_robots(fleet)) + homogeneous = fleet_is_homogeneous(fleet, root) + print(f"OK: {n} robot(s), " + f"{'homogeneous' if homogeneous else 'heterogeneous'} fleet") + return 0 + if args.json: + print(json.dumps(resolve_fleet(fleet, root), indent=2, sort_keys=True)) + return 0 + if args.table: + print_table(resolve_fleet(fleet, root)) + return 0 + + if args.robot: + key = args.robot + elif args.index is not None: + keys = [k for k, _ in _robots(fleet)] + if not 1 <= args.index <= len(keys): + raise FleetError( + f"--index {args.index} out of range (fleet has {len(keys)} robot(s))" + ) + key = keys[args.index - 1] + elif args.name: + key = resolve_identity(fleet, args.name) + else: + parser.error("one of --name/--index/--robot (or a mode flag) is required") + print_exports(resolve_robot(fleet, root, key), root) + return 0 + except FleetError as exc: + print(f"Error: {exc}", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + sys.exit(main())