Skip to main content

Running topologies in CI

· 5 min read

A topology that only runs on your laptop is a good start, but the real payoff comes when every pull request spins it up, runs the tests and throws it away again. This post walks through doing that in GitHub Actions: generate the topology, start it with a test container, let pytest talk to the RemotiveBroker, and fail the build when a test fails.

A tester container in the instance​

RemotiveTopology lets you add your own containers to an instance, next to the ECUs. The nice part for CI is that a test runner can be one of them: it starts inside the topology, on the same networks as everything else, and reaches the TopologyBroker by name. Put it behind a profile so it only runs when you ask for it:

containers:
tester:
profiles: [tester]
build:
dockerfile: ../../../python-runner.dockerfile
volumes:
- ../tests:/app
working_dir: /app
command: "pytest --broker_url=http://topology-broker.com:50051 -s -vv"

The Dockerfile only needs Python and your test dependencies: pytest, pytest-asyncio and the remotivelabs-topology package. Profiles map straight to docker compose profiles, so the tester stays out of the way when you start the topology without --profile tester to poke at it by hand.

One thing to check before moving to a hosted runner: CAN channels default to RemotiveBus, which needs to be installed on the host. If you don't need SocketCAN in CI, switch the instance to UDP emulation, which works anywhere without extra setup:

settings:
can:
default_driver: udp

Writing the tests​

Tests use the RemotiveTopology framework, the Python library you also write behavioral models with. A small conftest.py turns the --broker_url option from the container command into a BrokerClient fixture. The default points at localhost:50051, which is where the broker is forwarded when you run the topology interactively, so the same tests run from your editor too:

# tests/conftest.py
import pytest_asyncio
from remotivelabs.broker import BrokerClient


def pytest_addoption(parser):
parser.addoption("--broker_url", default="http://localhost:50051")


@pytest_asyncio.fixture
async def broker_client(request):
async with BrokerClient(url=request.config.getoption("--broker_url")) as client:
yield client

A test then subscribes to frames in a namespace (named <ECU>-<Channel>) and waits for the signal values it expects. This one is adapted from the testing examples in the API docs:

# tests/test_hazard_lights.py
from remotivelabs.broker import BrokerClient
from remotivelabs.topology.testing.frames import capture_frames


async def test_hazard_light_sequence(broker_client: BrokerClient) -> None:
async with capture_frames(
(broker_client, "HazardLightControlUnit-DriverCan0"),
frames=["HazardLightButton"],
) as cap:
await cap.wait_for_signal_values(
"HazardLightButton",
"HazardLightButton.HazardLightButton",
values=[0, 1, 0],
timeout=5.0,
)

The tests are async, so set asyncio_mode = auto in your pytest configuration (or mark them with @pytest.mark.asyncio). If you're unsure which namespaces exist, start the topology and run remotive broker signals namespaces --url http://localhost:50051.

Credentials for the runner​

Running a topology checks your organization's RemotiveTopology subscription, and a CI job shouldn't do that with someone's personal login. Create a service account with the RemotiveTopology Executor role instead:

remotive cloud service-accounts create ci-runner --role org/topologyRunner --project <project id>
remotive cloud service-accounts tokens create --service-account ci-runner --project <project id>
remotive cloud auth print-access-token --account <email for service account>
remotive cloud organizations list

Store the token as a repository secret, REMOTIVE_CLOUD_AUTH_TOKEN, and the organization uid as REMOTIVE_CLOUD_ORGANIZATION. If you run with a RemotiveBroker license file, keep it out of the repository: store it as a secret too and point REMOTIVEBROKER_LICENSE at it.

The workflow​

With that in place, the job is four steps: install the RemotiveCLI, generate the topology, start it with the tester profile, and clean up.

# .github/workflows/topology-tests.yml
name: Topology tests
on: [pull_request]

jobs:
topology-tests:
runs-on: ubuntu-latest
env:
REMOTIVE_CLOUD_AUTH_TOKEN: ${{ secrets.REMOTIVE_CLOUD_AUTH_TOKEN }}
REMOTIVE_CLOUD_ORGANIZATION: ${{ secrets.REMOTIVE_CLOUD_ORGANIZATION }}
steps:
- uses: actions/checkout@v4

- name: Install RemotiveCLI
run: pipx install remotivelabs-cli

- name: Generate topology
run: remotive topology generate -n mytopology -f topology/mytopology.instance.yaml build

- name: Run tests
run: >
docker compose -f build/mytopology/docker-compose.yml --profile tester
up --abort-on-container-exit --exit-code-from tester

- name: Clean up
if: always()
run: docker compose -f build/mytopology/docker-compose.yml --profile tester down

--abort-on-container-exit is what makes this work in CI: when pytest finishes, the tester container exits and docker compose stops the rest of the topology. Adding --exit-code-from tester, a plain docker compose option, makes the step's exit code pytest's, so a failing test fails the job. The down step runs even after a failure, so the runner is left clean.

Generate runs on every job, and that's on purpose: the generated files are completely overwritten each time, and any change to the platform or instance files needs a new generate anyway. Don't commit build/.

When it goes red​

Most failures are easier to read in the container logs than in the pytest output alone. Add a step with if: failure() that runs docker compose -f build/mytopology/docker-compose.yml logs before the clean-up, and you get the behavioral models' and brokers' side of the story too. The Docker tips page has more commands for following logs per service.

To reproduce locally, run the same generate, then start the topology without the tester profile, as described in Running a topology, and run pytest from your editor against localhost:50051. Same tests, same topology, just with a debugger attached.

About the author

Ola Nilsson

Builds the RemotiveLabs developer experience, from the docs and this blog to the tools around them. Usually found turning a support question into a guide, a guide into an example, and an example into a test that runs in CI. Has opinions about keyboard shortcuts.