Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# Build context for compose/docker/Dockerfile.hiroz (context = repo root).
# Only the Rust workspace is needed; keep the context small.
target
**/target
.git
.github
.vale
.config
assets
docs
nix
scripts
compose
**/.venv
crates/hiroz-go
crates/hiroz-codegen-go
flake.nix
flake.lock
mkdocs.yml
cliff.toml
codecov.yml
105 changes: 105 additions & 0 deletions .github/workflows/compose-interop.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
name: Compose Interop

# Cross-container interop gates: hiroz in its own container (the "Raspberry
# Pi analogue") talking zenoh through rmw_zenohd to a full ROS 2 Jazzy
# container that exercises topics, services, parameters, actions, and graph
# introspection. See compose/README.md.

on:
push:
branches: [main]
paths-ignore:
- '**.md'
- 'book/**'
- 'docs/**'
- 'mkdocs.yml'
- '.github/workflows/docs.yml'
- '.github/workflows/mkdocs-preview.yml'
- '.github/workflows/ci.yml'
- '.github/workflows/test.yml'
- '.github/workflows/rmw-zenoh-rs.yml'
- '.github/workflows/semantic-pr.yml'
- '.github/workflows/pr-draft-check.yml'
pull_request:
types: [opened, synchronize, reopened, ready_for_review]
branches: [main]
paths-ignore:
- '**.md'
- 'book/**'
- 'docs/**'
- 'mkdocs.yml'
- '.github/workflows/docs.yml'
- '.github/workflows/mkdocs-preview.yml'
- '.github/workflows/ci.yml'
- '.github/workflows/test.yml'
- '.github/workflows/rmw-zenoh-rs.yml'
- '.github/workflows/semantic-pr.yml'
- '.github/workflows/pr-draft-check.yml'
workflow_dispatch:
inputs:
platform:
description: "hiroz container platform (linux/arm64 runs the Pi analogue under QEMU)"
type: choice
options: [linux/amd64, linux/arm64]
default: linux/amd64

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}

jobs:
compose-interop:
name: compose interop (jazzy, ${{ inputs.platform || 'linux/amd64' }})
runs-on: ubuntu-latest
timeout-minutes: 60
# Skip for draft PRs to save CI time during development
if: |
github.event_name != 'pull_request' ||
github.event.pull_request.draft == false
env:
HIROZ_PLATFORM: ${{ inputs.platform || 'linux/amd64' }}
steps:
- uses: actions/checkout@v4

- name: Set up QEMU
if: env.HIROZ_PLATFORM == 'linux/arm64'
uses: docker/setup-qemu-action@v3
with:
platforms: arm64

- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3

- name: Build hiroz testbed image
uses: docker/build-push-action@v6
with:
context: .
file: compose/docker/Dockerfile.hiroz
platforms: ${{ env.HIROZ_PLATFORM }}
load: true
tags: hiroz-compose-testbed:local
cache-from: type=gha,scope=compose-hiroz-${{ env.HIROZ_PLATFORM }}
cache-to: type=gha,scope=compose-hiroz-${{ env.HIROZ_PLATFORM }},mode=max

- name: Build ros2 gates image
uses: docker/build-push-action@v6
with:
context: compose
file: compose/docker/Dockerfile.ros2
load: true
tags: hiroz-compose-ros2:local
cache-from: type=gha,scope=compose-ros2
cache-to: type=gha,scope=compose-ros2,mode=max

- name: Run interop gates
run: |
docker compose -f compose/docker-compose.yml up \
--no-build --exit-code-from gates gates

- name: Dump container logs on failure
if: failure()
run: docker compose -f compose/docker-compose.yml logs --timestamps

- name: Teardown
if: always()
run: docker compose -f compose/docker-compose.yml down -v --remove-orphans
90 changes: 90 additions & 0 deletions compose/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
# docker-compose interop harness

End-to-end RED/GREEN gates for hiroz ↔ ROS 2 interop across container
boundaries. Unlike the single-container interop tests in CI
(`.github/workflows/test.yml`), this harness runs hiroz in its **own**
container — a stand-in for a Raspberry Pi or similar edge device — talking
idiomatic zenoh over the network to a **separate** container with a full
ROS 2 Jazzy install.

## Topology

```text
┌─────────────────────┐ ┌──────────────────┐ ┌─────────────────────────┐
│ hiroz (testbed) │ │ router │ │ gates │
│ "Pi analogue" │─────▶│ rmw_zenohd │◀─────│ ROS 2 Jazzy + │
│ compose_testbed │ tcp/ │ (rmw_zenoh_cpp) │ tcp/ │ rmw_zenoh_cpp │
│ zenoh client mode │ 7447 │ │ 7447 │ runs /gates/*.sh │
└─────────────────────┘ └──────────────────┘ └─────────────────────────┘
```

- **router** — `ros2 run rmw_zenoh_cpp rmw_zenohd`, the documented rmw_zenoh
deployment. Using rmw_zenoh's own router keeps the router version matched
to the rmw side (jazzy currently bundles zenoh-c 1.6.x while hiroz uses
zenoh 1.9.x; a hiroz-side 1.9.x router would need the `gateway/south`
workaround — see `crates/hiroz-tests/tests/common/mod.rs`).
- **hiroz** — runs `crates/hiroz/examples/compose_testbed.rs`: talker,
ping→pong echo, AddTwoInts server, AddTwoInts client (polling a
ROS 2-hosted server), Fibonacci action server, and a parameter node.
- **gates** — runs `gates/run_gates.sh`; multicast scouting is disabled
everywhere, so the only rendezvous is `tcp/router:7447`.

## Gates

| Gate | What it proves |
|------|----------------|
| `01_topics` | hiroz talker → `ros2 topic echo /chatter`; `ros2 topic pub /ping` → hiroz echo → `/pong` |
| `02_services` | `ros2 service call /add_two_ints` against the hiroz server; hiroz client calls a ROS 2-hosted `demo_nodes_cpp` server |
| `03_parameters` | `ros2 param list/get/set` against the hiroz `param_node` |
| `04_actions_graph` | `ros2 node list`/`topic list` see hiroz entities via liveliness; full `ros2 action send_goal /fibonacci` goal→feedback→result |

Each gate prints `GREEN`/`RED` per check; `run_gates.sh` exits with the
number of failed gates, which `--exit-code-from gates` propagates.

## Running locally

```bash
docker compose -f compose/docker-compose.yml build
docker compose -f compose/docker-compose.yml up --no-build --exit-code-from gates gates
echo $? # 0 == all gates GREEN
docker compose -f compose/docker-compose.yml down -v --remove-orphans
```

To watch the other side, tail the testbed: `docker compose -f
compose/docker-compose.yml logs -f hiroz router`.

### Simulating a RED

- `docker compose -f compose/docker-compose.yml stop hiroz` mid-run → every
gate goes RED.
- Point the testbed at a wrong port in `docker-compose.yml` → the hiroz
healthcheck never passes and `depends_on` blocks the gates (non-zero exit).

## arm64 (Raspberry Pi architecture fidelity)

By default the hiroz container runs `linux/amd64`. To run it as
`linux/arm64` (cross-compiled with cargo-zigbuild; only the runtime stage is
emulated):

```bash
docker run --privileged --rm tonistiigi/binfmt --install arm64 # once per host
HIROZ_PLATFORM=linux/arm64 docker compose -f compose/docker-compose.yml build hiroz
HIROZ_PLATFORM=linux/arm64 docker compose -f compose/docker-compose.yml up --no-build --exit-code-from gates gates
```

In CI this is exposed as the `platform` input of the **Compose Interop**
workflow (`workflow_dispatch`); PR/push runs stay native for speed.

## CI

`.github/workflows/compose-interop.yml` pre-builds both images with buildx
(GitHub Actions layer cache), runs the harness with
`--exit-code-from gates`, dumps all container logs on failure, and tears
down the stack.

## Pinning rmw_zenoh_cpp

The ROS 2 image installs the latest `ros-jazzy-rmw-zenoh-cpp` on purpose so
the harness flags upstream regressions early. If a known-bad version lands,
pin it via the build arg: `RMW_ZENOH_VERSION="=<version>" docker compose
... build` (see `docker/Dockerfile.ros2`).
80 changes: 80 additions & 0 deletions compose/docker-compose.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
# docker-compose interop harness: a hiroz container (the "Raspberry Pi
# analogue") talks zenoh through a rmw_zenohd router to a full ROS 2 Jazzy
# container that exercises topics, services, parameters, actions, and graph
# introspection as RED/GREEN gates. See compose/README.md.
name: hiroz-compose-interop

services:
# rmw_zenoh's own router, so the router version always matches the rmw
# side (the documented deployment, docs/user-guide/interop.md).
router:
image: hiroz-compose-ros2:local
build:
context: .
dockerfile: docker/Dockerfile.ros2
args:
RMW_ZENOH_VERSION: ${RMW_ZENOH_VERSION:-}
pull_policy: never
command: ["ros2", "run", "rmw_zenoh_cpp", "rmw_zenohd"]
environment:
ROS_DOMAIN_ID: "0"
RUST_LOG: "zenoh=info"
healthcheck:
test: ["CMD", "bash", "-c", "exec 3<>/dev/tcp/127.0.0.1/7447"]
interval: 2s
timeout: 2s
retries: 15
start_period: 5s
networks: [rosnet]

# The Pi analogue: hiroz testbed nodes built from this repo, connecting to
# the router as a zenoh client. Set HIROZ_PLATFORM=linux/arm64 to run it
# under QEMU emulation for architectural fidelity (see README).
hiroz:
image: hiroz-compose-testbed:local
build:
context: ..
dockerfile: compose/docker/Dockerfile.hiroz
pull_policy: never
platform: ${HIROZ_PLATFORM:-linux/amd64}
command: ["--endpoint", "tcp/router:7447"]
environment:
RUST_LOG: "hiroz=info,zenoh=warn"
depends_on:
router:
condition: service_healthy
healthcheck:
test: ["CMD", "test", "-f", "/tmp/testbed_ready"]
interval: 2s
timeout: 2s
retries: 30
start_period: 5s
networks: [rosnet]

# The RED/GREEN gate runner: full ROS 2 Jazzy + rmw_zenoh_cpp exercising
# the hiroz container over the network. Its exit code is the verdict.
gates:
image: hiroz-compose-ros2:local
build:
context: .
dockerfile: docker/Dockerfile.ros2
args:
RMW_ZENOH_VERSION: ${RMW_ZENOH_VERSION:-}
pull_policy: never
command: ["/gates/run_gates.sh"]
environment:
ROS_DOMAIN_ID: "0"
RMW_IMPLEMENTATION: rmw_zenoh_cpp
# Same override format as hiroz-tests (common/mod.rs rmw_zenoh_env).
ZENOH_CONFIG_OVERRIDE: 'connect/endpoints=["tcp/router:7447"];scouting/multicast/enabled=false'
ZENOH_ROUTER_CHECK_ATTEMPTS: "30"
depends_on:
router:
condition: service_healthy
hiroz:
condition: service_healthy
networks: [rosnet]

networks:
rosnet:
driver: bridge
55 changes: 55 additions & 0 deletions compose/docker/Dockerfile.hiroz
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
# syntax=docker/dockerfile:1
# Builds the hiroz-side testbed for the docker-compose interop harness.
#
# The builder stages always run on the build host's native architecture
# ($BUILDPLATFORM); when TARGETPLATFORM=linux/arm64 the binary is
# cross-compiled with cargo-zigbuild so only the slim runtime stage runs
# under QEMU emulation.
#
# Build context must be the repository root (see compose/docker-compose.yml).

ARG RUST_VERSION=1.94

FROM --platform=$BUILDPLATFORM rust:${RUST_VERSION}-bookworm AS chef
RUN apt-get update \
&& apt-get install -y --no-install-recommends python3-pip \
&& rm -rf /var/lib/apt/lists/* \
&& pip3 install --break-system-packages cargo-zigbuild ziglang \
&& cargo install cargo-chef --locked
WORKDIR /app

FROM chef AS planner
COPY . .
RUN cargo chef prepare --recipe-path recipe.json

FROM chef AS builder
ARG TARGETPLATFORM
RUN case "${TARGETPLATFORM:-linux/amd64}" in \
"linux/arm64") echo aarch64-unknown-linux-gnu > /rust_target ;; \
"linux/amd64") echo x86_64-unknown-linux-gnu > /rust_target ;; \
*) echo "unsupported TARGETPLATFORM: ${TARGETPLATFORM}" >&2; exit 1 ;; \
esac \
&& rustup target add "$(cat /rust_target)"
# Cook the dependency tree as its own layer so source-only changes don't
# rebuild zenoh and friends.
COPY --from=planner /app/recipe.json recipe.json
RUN if [ "$(cat /rust_target)" = "x86_64-unknown-linux-gnu" ]; then \
cargo chef cook --release --target "$(cat /rust_target)" \
--package hiroz --recipe-path recipe.json; \
else \
cargo chef cook --release --zigbuild --target "$(cat /rust_target)" \
--package hiroz --recipe-path recipe.json; \
fi
COPY . .
RUN if [ "$(cat /rust_target)" = "x86_64-unknown-linux-gnu" ]; then \
cargo build --release --target "$(cat /rust_target)" \
-p hiroz --example compose_testbed; \
else \
cargo zigbuild --release --target "$(cat /rust_target)" \
-p hiroz --example compose_testbed; \
fi \
&& cp "target/$(cat /rust_target)/release/examples/compose_testbed" /compose_testbed

FROM debian:bookworm-slim AS runtime
COPY --from=builder /compose_testbed /usr/local/bin/compose_testbed
ENTRYPOINT ["/usr/local/bin/compose_testbed"]
25 changes: 25 additions & 0 deletions compose/docker/Dockerfile.ros2
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# Full ROS 2 Jazzy install used for both the zenoh router (rmw_zenohd) and
# the RED/GREEN gate runner in the docker-compose interop harness.
#
# Build context must be the compose/ directory (see compose/docker-compose.yml).
# Package list mirrors .github/workflows/test.yml.
FROM ros:jazzy-ros-base

# Optional apt version pin for rmw_zenoh_cpp, e.g. "=0.2.5-1noble.20250101.000000".
# Defaults to latest so the harness catches upstream rmw_zenoh regressions early.
ARG RMW_ZENOH_VERSION=

RUN apt-get update \
&& apt-get install -y --no-install-recommends \
"ros-jazzy-rmw-zenoh-cpp${RMW_ZENOH_VERSION}" \
ros-jazzy-demo-nodes-cpp \
ros-jazzy-example-interfaces \
ros-jazzy-action-tutorials-cpp \
&& rm -rf /var/lib/apt/lists/*

COPY gates/ /gates/
RUN chmod +x /gates/*.sh

# ros:jazzy ships /ros_entrypoint.sh which sources /opt/ros/jazzy/setup.bash.
ENTRYPOINT ["/ros_entrypoint.sh"]
CMD ["bash"]
Loading
Loading