Getting Started with EJ
Welcome to EJ - a modular and scalable framework for automated testing on physical embedded boards!
What is EJ?
EJ enables dispatching tests to real hardware, collecting logs, test results, and detect hardware-specific issues. The framework is designed to support diverse board architectures and simplify distributed hardware testing for embedded projects.
Key Components
EJ consists of two main applications and several supporting libraries:
Core Applications:
- EJB (EJ Builder) - Manages build processes and board communication
- EJD (EJ Dispatcher) - Handles job queuing, distribution, and result collection
- EJCli (EJ CLI) - Helper cli tool to interface with EJD
Libraries:
- ej-builder-sdk - Interface library for creating custom builder applications
- ej-dispatcher-sdk - Interface library for interfacing with dispatchers
- ej-auth - Authentication utilities (JWT management, password hashing)
- ej-config - Shared configuration structures and utilities
- ej-io - Program management utilities
- ej-models - Database models for EJ
- ej-requests - HTTP request handling utilities
- ej-web - Internal web utilities for the dispatcher
Architecture Overview
EJ follows a tree-like distributed architecture:
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ Git Repo │ │ CI/CD │ │ Developer │
│ │ │ │ │ │
└─────────┬───────┘ └─────────┬───────┘ └─────────┬───────┘
│ │ │
│ │ │
└──────────────────────┼──────────────────────┘
│
┌────────────▼────────────┐
│ EJD (Dispatcher) │
│ │
│ - Job Queuing │
│ - Result Storage │
│ - Authentication │
└────────────┬────────────┘
│
┌───────────────┼───────────────┐
│ │ │
┌────────────▼────────────┐ │ ┌────────────▼────────────┐
│ EJB (Builder 1) │ │ │ EJB (Builder 2) │
│ │ │ │ │
│ - Build Management │ │ │ - Build Management │
│ - Board Communication │ │ │ - Board Communication │
└────────────┬────────────┘ │ └────────────┬────────────┘
│ │ │
┌───────┼─────────┐ │ ┌───────┼────────┐
│ │ │ │ │ │ │
┌────▼──┐┌───▼───┐ ┌───▼───┐ │ ┌────▼──┐┌───▼───┐┌───▼───┐
│Board 1││Board 2│ │Board 3│ │ │Board 4││Board 5││Board 6│
│(RPi4) ││(ESP32)│ │(PC) │ │ │(RPi3) ││(STM32)││(x86) │
└───────┘└───────┘ └───────┘ │ └───────┘└───────┘└───────┘
Design Philosophy
EJ doesn't make assumptions about how to build, run, or manage your test results. This flexibility is achieved through:
- Builder SDK - Create custom build and run scripts with seamless builder communication and job cancellation support
- Dispatcher SDK - Interface with the dispatcher to dispatch jobs and retrieve results
This gives us complete control over how tests are built and deployed, how results are parsed, and board-specific configurations and requirements.
Guide Structure
This guide series will walk us through setting up and using EJ from basic to advanced scenarios:
01 - Builder
Learn how to set up our first EJ Builder with a basic configuration. We'll deploy a simple application to a Raspberry Pi as a practical example, covering:
- Installing and configuring EJB
- Creating your first board configuration
- Writing build and run scripts
- Deploying and testing a simple application
02 - Builder SDK
Discover why the Builder SDK exists and how it solves common deployment issues. We'll explore:
- The problems that can arise if we aren't careful with automatic deployments
- How the Builder SDK provides better control and monitoring
- Migrating from basic scripts to SDK-based solutions
03 - Dispatcher
Set up a centralized job management system with EJD. This guide covers:
- Installing and configuring the EJ Dispatcher
- Connecting builders to the dispatcher
- Managing jobs, queues, and results
04 - Dispatcher SDK
Create custom tools to interface with your dispatcher. Learn how to:
- Build a custom CLI tool using the Dispatcher SDK
- Submit jobs programmatically
- Parse and analyze results
Prerequisites
Before starting with the guides, ensure you have:
- Rust toolchain (latest stable version)
- Target hardware (Raspberry Pi recommended for examples)
- SSH access to your target boards
- Basic command line familiarity
- Git for version control
Next Steps
Ready to get started? Head over to Guide 01 - Builder to set up your first EJ Builder and deploy your first application!
Getting Help
- Issues: Report bugs and request features on our GitHub repository
- Documentation: Check the README.md and our crates in crates.io for API references
- Examples: Explore the
examples/
directory for configuration templates
EJ was originally designed and built for a bachelor thesis to provide LVGL with automatic performance measurement in CI. The architecture supports both small-scale local testing and large-scale distributed testing scenarios.
Guide 01: Setting Up Our First EJ Builder
This guide will walk us through setting up our first EJ Builder (EJB) and deploying a simple application to a Raspberry Pi. By the end of this guide, we'll have a working EJ Builder that can build and run applications on physical hardware.
Overview
In this guide, we'll:
- Install and configure EJB
- Set up a Raspberry Pi target board
- Configure board settings and deployment scripts
- Test the complete build and deployment workflow
Prerequisites
Before starting, ensure you have:
- A Raspberry Pi with Raspberry Pi OS 64 bit installed
- SSH access to your Raspberry Pi
- Cargo installed on your host machine
- The AArch64 GNU/Linux toolchain in your PATH.
- Basic familiarity with SSH and shell scripting is a bonus
Application example: K-mer Algorithm Performance Benchmark
For this guide, we'll use a k-mer counting application that:
- Processes the digits of PI to count k-mer occurrences
- Measures execution time and memory usage
- Outputs performance metrics to stdout
- Demonstrates computational differences between platforms
Note: K-mer algorithms are typically used in bioinformatics for analyzing DNA sequences (Wikipedia: K-mer). For this guide, we use the digits of PI as our input sequence since it provides a deterministic, easily reproducible dataset that still demonstrates the algorithm's computational characteristics.
This example showcases:
- Cross-platform deployment (development machine to Raspberry Pi)
- Performance measurement (timing and resource usage)
- Result collection (stdout capture)
- Real computational workload (pattern counting algorithm)
Step 1: Clone the kmer application
Application Code
We provide multiple versions of a kmer application
- An unoptimized version
- A single-threaded optimized version
- A multi-threaded optimized version
mkdir -p ~/ej-workspace
cd ~/ej-workspace
git clone https://github.com/embj-org/kmer.git
cd kmer
Cross compile the application
This is to ensure everything is working properly
cmake -B build-pi \
-DCMAKE_TOOLCHAIN_FILE=aarch64_toolchain.cmake
cmake --build build-pi -j$(nproc)
Test the application
PI_USERNAME=<your_pi_username>
PI_ADDRESS=<your_pi_ip_address>
scp -r build-pi/k-mer-omp inputs ${PI_USERNAME}@${PI_ADDRESS}:~
ssh ${PI_USERNAME}@${PI_ADDRESS} "./k-mer-omp inputs/pi_dec_1k.txt 3"
If any of these steps fail, ensure you have the correct toolchain installed and available in your PATH.
Now that we've ensured everything is working, it's time to use EJ.
Step 2: Install EJB
EJB is available in crates.io so you can install it like this:
cargo install ejb
Step 3: Create a build and run script
Inside ~/ej-workspace
, create the following scripts:
Build Script (build.sh
)
This script is responsible for building the application. We already did this previously so we can simply copy the same steps as before.
#!/bin/bash
set -e
SCRIPT=$(readlink -f $0)
SCRIPTPATH=$(dirname $SCRIPT)
cmake -B ${SCRIPTPATH}/kmer/build-pi \
-S ${SCRIPTPATH}/kmer \
-DCMAKE_TOOLCHAIN_FILE=${SCRIPTPATH}/kmer/aarch64_toolchain.cmake
cmake --build ${SCRIPTPATH}/kmer/build-pi -j$(nproc)
Run Script (run.sh
)
Same thing for the run script but right now what we'll be doing is only testing the original implementation. Additionally, we need to output the program results to a file so they can be used later. Finally, besides the results we'll actually time the application:
#!/bin/bash
set -e
PI_USERNAME=<your_pi_username>
PI_ADDRESS=<your_pi_ip_address>
SCRIPT=$(readlink -f $0)
SCRIPTPATH=$(dirname $SCRIPT)
scp -r ${SCRIPTPATH}/kmer/build-pi/k-mer-original \
${SCRIPTPATH}/kmer/inputs ${PI_USERNAME}@${PI_ADDRESS}:~
ssh ${PI_USERNAME}@${PI_ADDRESS} \
"time ./k-mer-original inputs/input.txt 3" 2>&1 | tee ${SCRIPTPATH}/results.txt
Making Scripts Executable
cd ~/ej-workspace
chmod +x build.sh run.sh
Step 4: Configuring EJB
Board Configuration
Inside ~/ej-workspace
Create your config.toml
. This file is responsible for describing every board and every board configuration EJB should handle.
We'll start with a very simple config file that describes our single board and a single config.
NOTE: Replace <user>
with your username.
[global]
version = "1.0.0"
[[boards]]
name = "Raspberry Pi"
description = "Raspberry Pi with Raspberry OS 64 bits"
[[boards.configs]]
name = "k-mer-original"
tags = ["arm64", "kmer unoptimized"]
build_script = "/home/<user>/ej-workspace/build.sh"
run_script = "/home/<user>/ej-workspace/run.sh"
results_path = "/home/<user>/ej-workspace/results_k-mer-original.txt"
library_path = "/home/<user>/ej-workspace/kmer"
You may notice the board config name, the results path and the executable all have the same name. This is NOT mandatory but will make it easier to build upon later on.
Configuration Explanation
- Board Definition: Describes your Raspberry Pi hardware
- Config Section: Defines how to build and run the k-mer benchmark
- Scripts: Point to your build and run scripts
- Results Path: Where EJB will look for captured stdout output
- Tags: Help categorize and filter boards
Step 5: Testing the config
Parse the config
EJB can be used to parse the file to make sure the config is correct
ejb --config config.toml parse
You should see the following output:
Configuration parsed successfully
Global version: 1.0.0
Number of boards: 1
Board 1: Raspberry Pi
Description: Raspberry Pi with Raspberry OS 64 bits
Configurations: 1
Config 1: k-mer-original
Tags: ["arm64", "kmer unoptimized"]
Build script: "/home/andre/ej-workspace/build.sh"
Run script: "/home/andre/ej-workspace/run.sh"
Results path: "/home/andre/ej-workspace/results_k-mer-original.txt"
Library path: "/home/andre/ej-workspace/kmer"
Test the config
You can now use EJB to run the described tests for you:
ejb --config config.toml validate
You should see this output followed by the test results
Validating configuration file: "config.toml"
2025-07-10T12:30:08.401673Z INFO ejb::build: Board 1/1: Raspberry Pi
2025-07-10T12:30:08.401682Z INFO ejb::build: Config 1: k-mer-original
2025-07-10T12:30:08.401882Z INFO ejb::build: Raspberry Pi - k-mer-original Build started
2025-07-10T12:30:08.512101Z INFO ejb::build: Raspberry Pi - k-mer-original Build ended successfully
2025-07-10T12:30:08.512427Z INFO ejb::run: k-mer-original - Run started
2025-07-10T12:30:10.054405Z INFO ejb::run: k-mer-original - Run ended successfully
2025-07-10T12:30:10.054516Z INFO ejb::run: Found results for Raspberry Pi - k-mer-original
========================
Log outputs for Raspberry Pi k-mer-original
========================
-- Configuring done (0.0s)
-- Generating done (0.0s)
-- Build files have been written to: /home/andre/ej-workspace/kmer/build-pi
[ 50%] Built target k-mer
[ 66%] Built target k-mer-original
[100%] Built target k-mer-omp
Results:
ABC: 2
BCD: 1
CDA: 1
DAB: 1
real 0m0.005s
user 0m0.000s
sys 0m0.005s
========================
Result for Raspberry Pi k-mer-original
========================
Results:
ABC: 2
BCD: 1
CDA: 1
DAB: 1
real 0m0.005s
user 0m0.000s
sys 0m0.005s
Step 6: Adding more configs
We showcased a pretty simple example to see how to setup one board with one config. In reality, EJ is equipped to handle multiple boards with multiple configs each.
The kmer
repository contains 3 versions of the same software, so let's use EJ to actually test the three versions but before, let's look into how how EJB launches our application.
Script Arguments
The scripts we created were pretty basic and only do one thing. We can easily imagine this becoming cumbersome very quickly.
EJ solves this problem by having EJB launch your build and run scripts with some arguments:
argv[0]
: Your script nameargv[1]
: Action (build
orrun
)argv[2]
: Config file pathargv[3]
: Board nameargv[4]
: Board config nameargv[5]
: Socket path for EJB communication. We'll be discussing this one further in a following guide.
With these arguments, we can actually create a more sophisticated script to handle every config for us.
Let's modify our run.sh
script to handle this for us:
#!/bin/bash
set -e
PI_USERNAME=<your_pi_username>
PI_ADDRESS=<your_pi_ip_address>
SCRIPT=$(readlink -f $0)
SCRIPTPATH=$(dirname $SCRIPT)
BOARD_CONFIG_NAME=$4
scp -r ${SCRIPTPATH}/kmer/build-pi/${BOARD_CONFIG_NAME} \
${SCRIPTPATH}/kmer/inputs ${PI_USERNAME}@${PI_ADDRESS}:~
ssh ${PI_USERNAME}@${PI_ADDRESS} \
"time ./${BOARD_CONFIG_NAME} inputs/pi_dec_1k.txt 3" 2>&1 | tee ${SCRIPTPATH}/results_${BOARD_CONFIG_NAME}.txt
Here, by using the board config name that is automatically passed to us by EJB (argv[4]
),
we can now use the same script for every board config.
This is the reason we matched the application name,
the results path and the board config name in our config.toml
earlier.
Add these new config entries at the bottom of your ~/ej-workspace/config.toml
:
NOTE: Replace <user>
with your username.
[[boards.configs]]
name = "k-mer"
tags = ["arm64", "kmer optimized"]
build_script = "/home/<user>/ej-workspace/build.sh"
run_script = "/home/<user>/ej-workspace/run.sh"
results_path = "/home/<user>/ej-workspace/results_k-mer.txt"
library_path = "/home/<user>/ej-workspace/kmer"
[[boards.configs]]
name = "k-mer-omp"
tags = ["arm64", "kmer multi-threaded optimized"]
build_script = "/home/<user>/ej-workspace/build.sh"
run_script = "/home/<user>/ej-workspace/run.sh"
results_path = "/home/<user>/ej-workspace/results_k-mer-omp.txt"
library_path = "/home/<user>/ej-workspace/kmer"
Don't hesitate to use ejb
to parse
your config and make sure it can be parsed written.
With this new config we can now run ejb
again and we'll see that it runs all three configs:
ejb --config config.toml validate
ejb --config config.toml validate
Validating configuration file: "config.toml"
2025-07-10T12:33:02.582019Z INFO ejb::build: Board 1/1: Raspberry Pi
2025-07-10T12:33:02.582045Z INFO ejb::build: Config 1: k-mer-original
2025-07-10T12:33:02.582278Z INFO ejb::build: Raspberry Pi - k-mer-original Build started
2025-07-10T12:33:02.692504Z INFO ejb::build: Raspberry Pi - k-mer-original Build ended successfully
2025-07-10T12:33:02.692524Z INFO ejb::build: Config 2: k-mer
2025-07-10T12:33:02.692779Z INFO ejb::build: Raspberry Pi - k-mer Build started
2025-07-10T12:33:02.802979Z INFO ejb::build: Raspberry Pi - k-mer Build ended successfully
2025-07-10T12:33:02.803001Z INFO ejb::build: Config 3: k-mer-omp
2025-07-10T12:33:02.803285Z INFO ejb::build: Raspberry Pi - k-mer-omp Build started
2025-07-10T12:33:02.913480Z INFO ejb::build: Raspberry Pi - k-mer-omp Build ended successfully
2025-07-10T12:33:02.913827Z INFO ejb::run: k-mer-original - Run started
2025-07-10T12:33:04.675982Z INFO ejb::run: k-mer-original - Run ended successfully
2025-07-10T12:33:04.676299Z INFO ejb::run: k-mer - Run started
2025-07-10T12:33:06.328239Z INFO ejb::run: k-mer - Run ended successfully
2025-07-10T12:33:06.328546Z INFO ejb::run: k-mer-omp - Run started
2025-07-10T12:33:07.870387Z INFO ejb::run: k-mer-omp - Run ended successfully
2025-07-10T12:33:07.870464Z INFO ejb::run: Found results for Raspberry Pi - k-mer-omp
2025-07-10T12:33:07.870479Z INFO ejb::run: Found results for Raspberry Pi - k-mer-original
2025-07-10T12:33:07.870484Z INFO ejb::run: Found results for Raspberry Pi - k-mer
========================
Log outputs for Raspberry Pi k-mer-original
========================
-- Configuring done (0.0s)
-- Generating done (0.0s)
-- Build files have been written to: /home/andre/ej-workspace/kmer/build-pi
[ 33%] Built target k-mer-original
[100%] Built target k-mer-omp
[100%] Built target k-mer
Results:
ABC: 2
BCD: 1
CDA: 1
DAB: 1
real 0m0.005s
user 0m0.000s
sys 0m0.005s
========================
Result for Raspberry Pi k-mer-original
========================
Results:
ABC: 2
BCD: 1
CDA: 1
DAB: 1
real 0m0.005s
user 0m0.000s
sys 0m0.005s
========================
Log outputs for Raspberry Pi k-mer
========================
-- Configuring done (0.0s)
-- Generating done (0.0s)
-- Build files have been written to: /home/andre/ej-workspace/kmer/build-pi
[ 33%] Built target k-mer-omp
[100%] Built target k-mer-original
[100%] Built target k-mer
Results:
ABC: 2
BCD: 1
CDA: 1
DAB: 1
real 0m0.005s
user 0m0.004s
sys 0m0.001s
========================
Result for Raspberry Pi k-mer
========================
Results:
ABC: 2
BCD: 1
CDA: 1
DAB: 1
real 0m0.005s
user 0m0.004s
sys 0m0.001s
========================
Log outputs for Raspberry Pi k-mer-omp
========================
-- Configuring done (0.0s)
-- Generating done (0.0s)
-- Build files have been written to: /home/andre/ej-workspace/kmer/build-pi
[ 33%] Built target k-mer-original
[ 66%] Built target k-mer
[100%] Built target k-mer-omp
Results:
ABC: 2
BCD: 1
CDA: 1
DAB: 1
real 0m0.008s
user 0m0.005s
sys 0m0.006s
========================
Result for Raspberry Pi k-mer-omp
========================
Results:
ABC: 2
BCD: 1
CDA: 1
DAB: 1
real 0m0.008s
user 0m0.005s
sys 0m0.006s
Understanding what just happened
When we run a job, EJB follows this process:
1. Build phase
EJB executes each build script sequentially
Build scripts are run sequentially. This allows us to use every available core to speed up our build process for each individual config.
Don't share build folders for multiple configs as EJB won't run our config before every other config has finished building.
2. Execution phase
EJB executes each run script
Run scripts are run in parallel across different boards and sequentially for each board config.
We must not share the same results path between multiple boards to avoid race conditions.
3. Result collection
EJB collects the results from the results_path
once the run finishes. This phase happens at the
same time as phase 2, the results are collected once the corresponding run script finishes.
We can use whatever we want as a way to represent our test results,
EJ will simply collect what's inside the results_path
at the moment the run_script
ends.
Next Steps
Congratulations! We now have a very simple but working EJ Builder setup which can already be used to automate our testing environment. The same way we created our Raspberry PI board, we could've just as easily added more board descriptions, there's no limit to how many boards and configs EJB can manage.
The simple shell script approach, although easy to setup, has some limitations, even for this simple example. If you can't think of any, don't worry, we'll dive into those in Guide 02 - Builder SDK which will present these issues and how the Builder SDK
solves them.
Guide 02: Understanding and Using the Builder SDK
You can find the SDK's documentation in crates.io
Overview
In the previous guide, we successfully set up a basic EJ Builder using shell scripts to deploy and test applications on embedded hardware. While this approach works well for simple scenarios, we may have encountered some limitations - particularly around handling long-running processes or cleaning up after interrupted tests.
This guide explores those limitations and demonstrates how the EJ Builder SDK provides robust solutions for production deployments. We'll learn how to convert our shell scripts into a proper Rust application that can handle job cancellation, manage resources properly, and integrate seamlessly with advanced EJ features.
By the end of this guide, we'll have a production-ready builder setup that can handle complex deployment scenarios with confidence.
Prerequisites
Before starting this guide, ensure you have:
- Completed Guide 01: This guide builds directly on the EJ Builder setup from the previous guide
- Rust toolchain installed: We'll need
cargo
and the Rust compiler- Install via rustup.rs if we haven't already
- No prior Rust experience required - the guide explains all concepts as we go
- Our working EJ Builder setup: From the previous guide, including:
- The
kmer
project configured and working - SSH access to your target device (Raspberry Pi)
- The
config.toml
file with your board configurations
- The
- Understanding of the shell script approach: You should have successfully run the previous guide's shell scripts
The Problem with Basic Script Deployment
In the previous guide, we set up a basic EJ Builder using shell scripts. While this approach works for simple scenarios, you may have noticed some limitations.
Let's revisit what happens when we deploy applications using basic SSH and shell scripts, particularly with our Raspberry Pi example from Guide 01.
For this, let's add this new config to our ~/ej-workspace/config.toml
:
NOTE: Replace <user>
with your username.
[[boards.configs]]
name = "infinite-loop"
tags = ["arm64", "infinite-loop"]
build_script = "/home/<user>/ej-workspace/build.sh"
run_script = "/home/<user>/ej-workspace/run.sh"
results_path = "/home/<user>/ej-workspace/results_infinite-loop.txt"
library_path = "/home/<user>/ej-workspace/kmer"
The application we will run enters an infinite loop, meaning the application will never exit.
ejb --config config.toml validate
It won't take long to see the problem:
Validating configuration file: "config.toml"
2025-07-10T13:19:55.029647Z INFO ejb::build: Board 1/1: Raspberry Pi
2025-07-10T13:19:55.029655Z INFO ejb::build: Config 1: k-mer-original
2025-07-10T13:19:55.029879Z INFO ejb::build: Raspberry Pi - k-mer-original Build started
2025-07-10T13:19:55.140067Z INFO ejb::build: Raspberry Pi - k-mer-original Build ended successfully
2025-07-10T13:19:55.140084Z INFO ejb::build: Config 2: k-mer
2025-07-10T13:19:55.140310Z INFO ejb::build: Raspberry Pi - k-mer Build started
2025-07-10T13:19:55.250487Z INFO ejb::build: Raspberry Pi - k-mer Build ended successfully
2025-07-10T13:19:55.250504Z INFO ejb::build: Config 3: k-mer-omp
2025-07-10T13:19:55.250722Z INFO ejb::build: Raspberry Pi - k-mer-omp Build started
2025-07-10T13:19:55.360908Z INFO ejb::build: Raspberry Pi - k-mer-omp Build ended successfully
2025-07-10T13:19:55.360933Z INFO ejb::build: Config 4: infinite-loop
2025-07-10T13:19:55.361238Z INFO ejb::build: Raspberry Pi - infinite-loop Build started
2025-07-10T13:19:55.471432Z INFO ejb::build: Raspberry Pi - infinite-loop Build ended successfully
2025-07-10T13:19:55.471698Z INFO ejb::run: k-mer-original - Run started
2025-07-10T13:19:56.903312Z INFO ejb::run: k-mer-original - Run ended successfully
2025-07-10T13:19:56.903571Z INFO ejb::run: k-mer - Run started
2025-07-10T13:19:58.114931Z INFO ejb::run: k-mer - Run ended successfully
2025-07-10T13:19:58.115205Z INFO ejb::run: k-mer-omp - Run started
2025-07-10T13:19:59.326567Z INFO ejb::run: k-mer-omp - Run ended successfully
2025-07-10T13:19:59.326831Z INFO ejb::run: infinite-loop - Run started
The underlying application entered an infinite loop and thus both EJB and our run.sh
script are stuck forever waiting for it to end.
To quit it, we can press CTRL+C
essentially killing EJB and the run.sh
script process that is holding the ssh connection.
Now if we run the validation again:
ejb --config config.toml validate
Something we may not expect happens - EJB (or rather the underlying run.sh
script) fails!
Taking a look at the logs, we can see that scp
failed because the infinite-loop
file is locked as it's still being executed inside our target device.
scp: dest open "./infinite-loop": Failure
scp: failed to upload file /home/andre/ej-workspace/kmer/build-pi/infinite-loop to ~
To actually stop it we need to connect to our Raspberry Pi and kill it:
ssh ${PI_USERNAME}@${PI_ADDRESS} "killall infinite-loop"
This poses a real problem when deploying this to a production environment as we'd like to make sure that if one job fails, we want to be able to simply consider this job as a failure and run a new job later without having to manually connect to our target board to clean up failed job leftovers.
EJ solves this problem by providing an SDK - called EJ Builder SDK
- that handles communicating with EJB through an exposed Unix Socket.
The Builder SDK abstracts all of this for us with some pretty simple boilerplate code. Let's create a script to see what it looks like.
Step 1: Setup an application with the EJB SDK
cd ~/ej-workspace
cargo init --bin ejkmer-builder
cd ejkmer-builder
cargo add ej-builder-sdk # EJB SDK
cargo add tokio -F macros -F rt-multi-thread -F process # Async runtime
cargo add num_cpus # (Optional) Used to be able to write -j$(nprocs) during the build phase
Now let's add this boilerplate code to our src/main.rs
file:
use ej_builder_sdk::{Action, BuilderEvent, BuilderSdk, prelude::*}; #[tokio::main] async fn main() -> Result<()> { let sdk = BuilderSdk::init(|sdk, event| async move { match event { BuilderEvent::Exit => todo!("Handle exit command"), } }) .await?; match sdk.action() { Action::Build => todo!("Handle build command"), Action::Run => todo!("Handle run command"), } }
Let's go through each line of code to understand what it's going on:
#![allow(unused)] fn main() { use ej_builder_sdk::{Action, BuilderEvent, BuilderSdk, prelude::*}; }
This line is including stuff we need from the ej_builder_sdk
that we added to our project when we ran the cargo add ej-builder-sdk
command.
-
Action
: is a Rust Enum used to describe the action this script should take (eitherBuild
orRun
). This lets us use the same script as our build and run script - although this isn't mandatory. -
BuilderEvent
: is a Rust Enum that describe an Event received by EJB. For now, the only event we can expect isExit
but there may be others in the future as EJ evolves. -
BuilderSDK
: is the main BuilderSDK data structure, it will contain every information passed by EJB, this includes:- The action to take (
Build
orRun
) - The path to the
config.toml
file - The current board name
- The current board config name
These informations allow us to use a single script to handle building and testing our application throughout multiple boards and configs.
- The action to take (
-
prelude::*
: is the BuilderSDK crate prelude that imports aResult
and a commonError
type that can be used by your script.
#[tokio::main] async fn main() -> Result<()> {
These two lines allow us to describe our main function as Asynchronous - Wikipedia.
BuilderSDK uses async tasks under the hood to manage the connection with EJB in a transparent way so this will allow us to call these functions.
The return type of our main function is the Result
type. This Result
type is pulled from the BuilderSDK prelude
and uses its internal Error
type as the Result
error type.
This allows us to use the ?
operator to easily handle errors in our application.
#![allow(unused)] fn main() { let sdk = BuilderSdk::init(|sdk, event| async move { match event { BuilderEvent::Exit => todo!("Handle exit command"), } }) .await?; }
This portion of code initializes the BuilderSDK
. The return type will be a BuilderSDK
or an error if something went wrong during the initialization process.
The BuilderSDK::init
function takes in an async
function callback that will be called when it receives a new event from EJB.
This lets us handle these events the way we see fit (e.g., by killing the process in our target board when we receive an exit request).
The .await
is necessary because the init function is async
, this essentially tells the program to wait for the
execution of this call instead of deferring it for later.
The ?
operator will return from the main function (and thus the application) if the BuilderSDK::init
function returns an error. In this case, the exit code of the application will be non-zero.
#![allow(unused)] fn main() { match sdk.action() { Action::Build => todo!("Handle build command"), Action::Run => todo!("Handle run command"), } }
Now that we've initialized everything, the sdk
variable holds every information passed by EJB.
We can use it to query the action to take, the path to the config file, the board name and the board config name allowing us to create generic scripts that handle our build and deployment needs.
Here we are matching on the action to take (either Action::Build
or Action::Run
).
Let's now write what the application should do when asked to build and run our application.
Step 2: Convert our build shell script to Rust code
As a reminder, our current build script looks like this:
cmake -B ${SCRIPTPATH}/kmer/build-pi \
-S ${SCRIPTPATH}/kmer \
-DCMAKE_TOOLCHAIN_FILE=${SCRIPTPATH}/kmer/aarch64_toolchain.cmake
cmake --build ${SCRIPTPATH}/kmer/build-pi -j$(nproc)
First off let's write some utility functions to manage the paths we need for this.
As a reminder, EJB provides us with the absolute path to our config.toml
,
following the directory structure we set up for our project we can find the workspace folder as the parent of our config.toml
file:
#![allow(unused)] fn main() { use std::path::{Path, PathBuf} fn workspace_folder(config_path: &Path) -> PathBuf { config_path .parent() .expect(&format!( "Failed to get folder containing `config.toml` - Config path is: {}", config_path.display() )) .to_path_buf() } }
The source folder sits inside our workspace folder:
#![allow(unused)] fn main() { fn source_folder(config_path: &Path) -> PathBuf { workspace_folder(config_path).join("kmer") } }
And our build folder and toolchain file sit inside the source folder:
#![allow(unused)] fn main() { fn build_folder(config_path: &Path) -> PathBuf { source_folder(config_path).join("build-pi") } fn toolchain_file(config_path: &Path) -> PathBuf { source_folder(config_path).join("aarch64_toolchain.cmake") } }
Once we have these helper functions we can write a very elegant build function:
#![allow(unused)] fn main() { use tokio::process::Command; async fn build_application(sdk: &BuilderSdk) -> Result<()> { let config_path = &sdk.config_path(); let status = Command::new("cmake") .arg("-B") .arg(build_folder(config_path)) .arg("-S") .arg(source_folder(config_path)) .arg(&format!( "-DCMAKE_TOOLCHAIN_FILE={}", toolchain_file(config_path).display() )) .spawn()? .wait() .await?; assert!(status.success(), "CMake execution failed"); Command::new("cmake") .arg("--build") .arg(build_folder(config_path)) .arg("-j") .arg(num_cpus::get().to_string()) .spawn()? .wait() .await?; assert!(status.success(), "Build failed"); Ok(()) } }
NOTE: We use the tokio::process
module instead of std::process
to keep our code async.
Be careful calling sync functions from async code. We recommend this tokio guide
that explains how to bridge the two if you're interested.
Step 3: Convert our run shell script to Rust code
Following the same process with the helper functions we can write similar to our original shell script:
scp -r ${SCRIPTPATH}/kmer/build-pi/${BOARD_CONFIG_NAME} \
${SCRIPTPATH}/kmer/inputs ${PI_USERNAME}@${PI_ADDRESS}:~
ssh ${PI_USERNAME}@${PI_ADDRESS} \
"time ./${BOARD_CONFIG_NAME} inputs/input.txt 3" 2>&1 | tee ${SCRIPTPATH}/results_${BOARD_CONFIG_NAME}.txt
#![allow(unused)] fn main() { const PI_USERNAME: &str = ""; const PI_ADDRESS: &str = ""; fn application_path(config_path: &Path, application_name: &str) -> PathBuf { build_folder(config_path).join(application_name) } fn inputs_path(config_path: &Path) -> PathBuf { source_folder(config_path).join("inputs") } fn results_path(config_path: &Path, application_name: &str) -> PathBuf { workspace_folder(config_path).join(format!("results_{}", application_name)) } async fn run_application(sdk: &BuilderSdk) -> Result<()> { let config_path = &sdk.config_path(); let app_name = &sdk.board_config_name(); let result = Command::new("scp") .arg("-r") .arg(application_path(config_path, app_name)) .arg(inputs_path(config_path)) .arg(&format!("{PI_USERNAME}@{PI_ADDRESS}:~")) .spawn()? .wait() .await?; assert!(result.success(), "SCP execution failed"); let result = Command::new("ssh") .arg(&format!("{}@{}", PI_USERNAME, PI_ADDRESS)) .arg(&format!("time ./{} inputs/input.txt 3", app_name)) .spawn()? .wait_with_output() .await?; let stdout = String::from_utf8_lossy(&result.stdout); let stderr = String::from_utf8_lossy(&result.stderr); assert!(result.status.success(), "SSH execution failed"); std::fs::write( results_path(config_path, app_name), format!("{}\n{}", stdout, stderr), )?; Ok(()) } }
Step 4: Handling cancellation using the EJ Builder SDK
Finally, the reason we started the journey of writing a Rust program instead of a shell script was to be able to handle cancelling our job correctly to not leave a process running forever in our Raspberry Pi.
Here we can open a new SSH connection to kill the process running on our target board, the same way we did manually before:
#![allow(unused)] fn main() { async fn kill_application_in_rpi(sdk: &BuilderSdk) -> Result<()> { let result = Command::new("ssh") .arg(format!("{PI_USERNAME}@{PI_ADDRESS}")) .arg(format!("killall {}", sdk.board_config_name())) .spawn()? .wait() .await?; assert!(result.success(), "Failed to kill process in RPI"); Ok(()) } }
Step 5: Putting it all together
Using our new functions, we can finish off writing our main application:
NOTE: Replace PI_USERNAME
and PI_ADDRESS
with their corresponding values.
use std::path::{Path, PathBuf}; use tokio::process::Command; use ej_builder_sdk::{Action, BuilderEvent, BuilderSdk, prelude::*}; const PI_USERNAME: &str = ""; const PI_ADDRESS: &str = ""; async fn kill_application_in_rpi(sdk: &BuilderSdk) -> Result<()> { let result = Command::new("ssh") .arg(format!("{PI_USERNAME}@{PI_ADDRESS}")) .arg(format!("killall {}", sdk.board_config_name())) .spawn()? .wait() .await?; assert!(result.success(), "Failed to kill process in RPI"); Ok(()) } fn workspace_folder(config_path: &Path) -> PathBuf { config_path .parent() .expect(&format!( "Failed to get folder containing `config.toml` - Config path is: {}", config_path.display() )) .to_path_buf() } fn source_folder(config_path: &Path) -> PathBuf { workspace_folder(config_path).join("kmer") } fn build_folder(config_path: &Path) -> PathBuf { source_folder(config_path).join("build-pi") } fn toolchain_file(config_path: &Path) -> PathBuf { source_folder(config_path).join("aarch64_toolchain.cmake") } fn application_path(config_path: &Path, application_name: &str) -> PathBuf { build_folder(config_path).join(application_name) } fn inputs_path(config_path: &Path) -> PathBuf { source_folder(config_path).join("inputs") } fn results_path(config_path: &Path, application_name: &str) -> PathBuf { workspace_folder(config_path).join(format!("results_{}", application_name)) } async fn run_application(sdk: &BuilderSdk) -> Result<()> { let config_path = &sdk.config_path(); let app_name = &sdk.board_config_name(); let result = Command::new("scp") .arg("-r") .arg(application_path(config_path, app_name)) .arg(inputs_path(config_path)) .arg(&format!("{PI_USERNAME}@{PI_ADDRESS}:~")) .spawn()? .wait() .await?; assert!(result.success(), "SCP execution failed"); let result = Command::new("ssh") .arg(&format!("{}@{}", PI_USERNAME, PI_ADDRESS)) .arg(&format!("time ./{} inputs/input.txt 3", app_name)) .spawn()? .wait_with_output() .await?; let stdout = String::from_utf8_lossy(&result.stdout); let stderr = String::from_utf8_lossy(&result.stderr); assert!(result.status.success(), "SSH execution failed"); std::fs::write( results_path(config_path, app_name), format!("{}\n{}", stdout, stderr), )?; Ok(()) } async fn build_application(sdk: &BuilderSdk) -> Result<()> { let config_path = &sdk.config_path(); let status = Command::new("cmake") .arg("-B") .arg(build_folder(config_path)) .arg("-S") .arg(source_folder(config_path)) .arg(&format!( "-DCMAKE_TOOLCHAIN_FILE={}", toolchain_file(config_path).display() )) .spawn()? .wait() .await?; assert!(status.success(), "CMake execution failed"); Command::new("cmake") .arg("--build") .arg(build_folder(config_path)) .arg("-j") .arg(num_cpus::get().to_string()) .spawn()? .wait() .await?; assert!(status.success(), "Build failed"); Ok(()) } #[tokio::main] async fn main() -> Result<()> { let sdk = BuilderSdk::init(|sdk, event| async move { match event { BuilderEvent::Exit => kill_application_in_rpi(&sdk).await, } }) .await?; match sdk.action() { Action::Build => build_application(&sdk).await, Action::Run => run_application(&sdk).await, } }
Now, whenever a job is cancelled by either EJB or EJD (Guide 03) the script will receive the Exit
event and will clean the necessary resources.
Step 6: Build your application
cd ~/ej-workspace/ejkmer-builder
cargo build --release
The application is now available inside the ~/ej-workspace/ejkmer-builder/target/release
folder.
Step 7: Update your EJB config
We can use this new application to handle every build and run configuration so now we need to tell EJB, through its config, to use it.
We can use some sed
magic to avoid having to change every line manually:
sed -i 's/\/[b|r].*.sh/ejkmer-builder\/target\/release\/ejkmer-builder/g' ~/ej-workspace/config.toml
Here's the final result:
NOTE: Replace <user>
with your username
[global]
version = "1.0.0"
[[boards]]
name = "Raspberry Pi"
description = "Raspberry Pi with Raspberry OS 64 bits"
[[boards.configs]]
name = "k-mer-original"
tags = ["arm64", "kmer unoptimized"]
build_script = "/home/<user>/ej-workspace/ejkmer-builder/target/release/ejkmer-builder"
run_script = "/home/<user>/ej-workspace/ejkmer-builder/target/release/ejkmer-builder"
results_path = "/home/<user>/ej-workspace/results_k-mer-original.txt"
library_path = "/home/<user>/ej-workspace/kmer"
[[boards.configs]]
name = "k-mer"
tags = ["arm64", "kmer optimized"]
build_script = "/home/<user>/ej-workspace/ejkmer-builder/target/release/ejkmer-builder"
run_script = "/home/<user>/ej-workspace/ejkmer-builder/target/release/ejkmer-builder"
results_path = "/home/<user>/ej-workspace/results_k-mer.txt"
library_path = "/home/<user>/ej-workspace/kmer"
[[boards.configs]]
name = "k-mer-omp"
tags = ["arm64", "kmer multi-threaded optimized"]
build_script = "/home/<user>/ej-workspace/ejkmer-builder/target/release/ejkmer-builder"
run_script = "/home/<user>/ej-workspace/ejkmer-builder/target/release/ejkmer-builder"
results_path = "/home/<user>/ej-workspace/results_k-mer-omp.txt"
library_path = "/home/<user>/ej-workspace/kmer"
[[boards.configs]]
name = "infinite-loop"
tags = ["arm64", "infinite-loop"]
build_script = "/home/<user>/ej-workspace/ejkmer-builder/target/release/ejkmer-builder"
run_script = "/home/<user>/ej-workspace/ejkmer-builder/target/release/ejkmer-builder"
results_path = "/home/<user>/ej-workspace/results_infinite-loop.txt"
library_path = "/home/<user>/ej-workspace/kmer"
TIP
Putting the application in our $PATH
will make it easier to invoke it, for this we recommend
installing it in our PC directly:
cargo install --path ~/ej-workspace/ejkmer-builder
With the application installed you can set every build and run scripts in your config file like this:
build_script = "ejkmer-builder"
run_script = "ejkmer-builder"
And of course a sed
command to avoid having to do it manually:
sed -i 's/script = .*/script = "ejkmer-builder"/g' ~/ej-workspace/config.toml
This makes our config.toml
easier to read and allows us to freely move our source code if we wish so.
Step 8: Test the new script
Make sure you've cleaned up the running process in your raspberry pi:
ssh ${PI_USERNAME}@${PI_ADDRESS} "killall infinite-loop"
cd ~/ej-workspace
ejb --config config.toml validate
We can again quit EJB with CTRL+C
and we'll be able to see that the infinite-loop
is not running on our Raspberry Pi even after abruptly quitting the whole process.
ssh ${PI_USERNAME}@${PI_ADDRESS} "killall infinite-loop"
infinite-loop: no process found
Advantages of using the EJ Builder SDK
- Proper cancellation handling. When EJB sends an exit signal, your script can clean up running processes on target devices instead of leaving them orphaned
- Single binary approach. One application handles both building and running (though you could do this with shell scripts too, it's just arguably harder)
- Custom result formats. Our example just saves program output to a file, but we can collect and format results however makes sense for our use case
- Easy integration testing. Write tests that spawn TCP listeners, launch our program on the target device, and verify the results in real-time
- Unlimited possibilities. Once we're using a real programming language, we can do things like:
- Monitor system resources (CPU, memory, network) during test execution
- Send notifications to Slack when tests complete
- Generate detailed HTML reports with charts and graphs
- Job cancellation support with EJD (Guide 03)
- You get to write rust code
Disadvantages of using the EJ Builder SDK
- Setup overhead. It takes longer to get started compared to throwing together a quick shell script
- Compile-test cycle: Every change requires a
cargo build
before you can test it, which slows down rapid iteration- Can be minimized by tools like
cargo-watch
- Can be minimized by tools like
- Rust knowledge required: You need to be comfortable with Rust syntax, and async programming
- Though the SDK could be ported to other languages very easily. Contributions are welcome
- Binary management: Need to keep track of compiled binaries and make sure they're available where EJB expects them.
- Installing the application with
cargo install
solves this
- Installing the application with
- Overkill for simple tasks: If we're just running basic commands and don't need to clean up any resources when a job fails, a shell script might be simpler
- E.g., when running tests in an MCU where every deployment overwrites the board's flash memory.
- You get to write rust code
Next Steps
At this point, we have a fully functional EJ Builder setup that can handle complex deployments with proper cancellation handling. EJB works perfectly fine as a standalone tool - we can integrate it into CI/CD pipelines or use it on our development machine to spin up integration tests in the background while we work on other tasks.
You may have noticed that throughout this guide, we haven't stored results anywhere and we've only worked with a single builder. This is completely fine for many use cases, but if you're looking at larger-scale deployments with multiple builders, you might want something more robust.
In Guide 03 - Dispatcher, we'll explore the EJ Dispatcher (EJD) a tool that can:
- Manage multiple builders simultaneously
- Queue and distribute jobs across your hardware fleet
- Store and organize results from multiple test runs
- Provide authentication and access control
- Enable remote job submission and monitoring
The dispatcher transforms EJ from a single-builder tool into a powerful distributed testing platform, but it's entirely optional depending on our needs.
Best Practice: Always use the Builder SDK for production deployments, especially for long-running applications.
Guide 03: Setting Up an EJ Dispatcher
Overview
In the previous guides, we learned how to set up a single EJ Builder and run embedded tests on target hardware. While this works perfectly for individual development and experimentation, real-world embedded development often involves teams, multiple hardware configurations, and integration with CI/CD pipelines.
This guide introduces the EJ Dispatcher (EJD) - a centralized service that transforms our individual EJ Builder into a scalable, multi-user testing infrastructure. We'll learn how to set up a dispatcher, connect multiple builders, and coordinate testing across our development team.
Prerequisites
Before starting this guide, ensure you have:
- Completed Guide 02: This guide builds directly on the EJ Builder setup from the previous guide
- Docker and Docker Compose: Required for running the EJD service
- Basic Linux permissions knowledge: We'll be working with Unix sockets and user groups
- Network access: If deploying across multiple machines
- Your working EJ Builder setup: From the previous guide, including the
kmer
project configuration andej-workspace
What is the EJ Dispatcher?
The EJ Dispatcher acts as the central coordinator for your embedded testing infrastructure. Think of it as a "job scheduler" that sits between your development workflow and your physical hardware.
Without EJD: Each developer runs their own EJ Builder instance, manually coordinating access to hardware and managing their own test results.
With EJD: A single dispatcher manages a pool of builders and hardware, automatically queuing jobs, distributing work, and storing results in a centralized location.
Key capabilities include:
- Centralized Job Management: Queue and automatically distribute test jobs across available builders
- Multi-Builder Coordination: Manage fleets of builders with different hardware configurations
- Team Collaboration: Multiple developers can submit jobs without hardware conflicts
- Result Storage & History: Centralized database stores all test results with full historical tracking
- Secure Access: Authentication system controls who can submit jobs and access results
- CI/CD Integration: REST API enables integration with automated build pipelines
When You Need a Dispatcher
The EJ Dispatcher becomes essential when you encounter these scenarios:
Team Development: Multiple developers need to share the same embedded hardware without conflicts. The dispatcher automatically queues jobs and ensures fair access to resources.
CI/CD Integration: Your build pipeline needs to automatically run embedded tests on every commit. The dispatcher provides the API endpoints and job management needed for automation.
Hardware Scaling: You have multiple development boards, different hardware configurations, or geographically distributed test setups. The dispatcher can coordinate across all of them.
Result Management: You need to track performance trends, compare results across builds, or maintain historical test data. The dispatcher provides centralized storage and querying capabilities.
Production Testing: You're running embedded tests as part of your release process and need reliability, monitoring, and audit trails that a centralized system provides.
Architecture Overview
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ Git Repo │ │ CI/CD │ │ Developer │
│ │ │ │ │ │
└─────────┬───────┘ └─────────┬───────┘ └─────────┬───────┘
│ │ │
│ ┌───────────┼───────────┐ │
│ │ │ │ │
└──────────┼───────────┼───────────┼──────────┘
│ │ │
┌────▼───────────▼───────────▼────┐
│ EJD (Dispatcher) │
│ │
│ ┌─────────────────────────────┐│
│ │ Job Queue Manager ││
│ │ - Queue and dispatch jobs ││
│ └─────────────────────────────┘│
│ ┌─────────────────────────────┐│
│ │ Result Storage ││
│ │ - Database management ││
│ │ - Historical data ││
│ └─────────────────────────────┘│
│ ┌─────────────────────────────┐│
│ │ Authentication ││
│ │ - Builder Registration ││
│ │ - Client Access Control ││
│ └─────────────────────────────┘│
└────┬───────────┬───────────┬────┘
│ │ │
┌───────────┼───────────┼───────────┼───────────┐
│ │ │ │ │
┌────▼────┐ ┌────▼────┐ ┌────▼────┐ ┌────▼────┐ ┌────▼────┐
│ EJB #1 │ │ EJB #2 │ │ EJB #3 │ │ EJB #4 │ │ EJB #N │
│ (Local) │ │ (RPi) │ │ (Cloud) │ │ (Lab) │ │ (...) │
└─────────┘ └─────────┘ └─────────┘ └─────────┘ └─────────┘
Step 1: Installing EJD
The easiest way to deploy EJD is using the official Docker
setup.
EJ provides a ready-to-use git repository
that uses Docker
and Docker Compose
to setup EJD.
git clone https://github.com/embj-org/ejd-deployment ~/ejd-deployment
cd ~/ejd-deployment
# Follow the prompts to setup your env variables
./setup.sh
# Download and launch EJD along with a postgresql database
docker compose up -d
TLS Support
If you're looking into deploying EJD into a public network, we very highly recommend setting it up with TLS support.
A skeleton example is available in the same git repository inside the tls-example
folder.
It uses Traefik
to set up a reverse proxy which will provide TLS support for you.
NOTE: It's not a ready-to-use example. Many things depend on your specific setup like DNS provider and URL but it should help you getting started.
Step 2: Set up permissions to access the EJD socket
During setup, EJD create an Unix Socket that can be used to communicate with the tool. By default, we need root
permissions to access this socket.
This is a security measure as EJD doesn't perform any sort of authentication when communicating through the socket. Instead the authentication is managed by Linux.
In most setups it'll be used to create the first application user and test our setup and for this we must be explicit about giving ourselves permissions.
Here we will create a new group
called ejd
and change the group
ownership of the socket file to this new group.
Once we have done that, we'll add the current user to the ejd
group which will allow us to use the socket
provided by EJD without having to prefix every command with sudo
.
# Create the `ejd` group
sudo groupadd ejd
# Add yourself to the `ejd` group
sudo usermod -a -G ejd $USER
# Change the group ownership of the socket file to 'ejd'
# This allows members of the 'ejd' group to access the file if permissions allow
sudo chown :ejd ~/ejd-deployment/ejd/tmp/ejd.sock
# Grant write permissions to the group for the socket file
# This enables 'ejd' group members to write to the socket
sudo chmod g+w ~/ejd-deployment/ejd/tmp/ejd.sock
# Activate the group changes in the current shell session
# You may need to log out and login again
newgrp ejd
Step 3: Create your first user
EJ provides ejcli
, a cli tool that interfaces with EJD.
You can install it the same way we installed EJB in the first guide
cargo install ejcli
Now create your first user.
NOTE: Replace <username>
in the command and enter your password when prompted.
ejcli create-root-user --socket ~/ejd-deployment/ejd/tmp/ejd.sock --username <username>
Creating user
Password >
CreateRootUserOk(EjClientApi { id: 63c16857-0372-4add-a5bf-c0bd266fe650, name: "<username>" })
Step 4: Register your builder
To create the builder, we'll use the rest API interface from EJD. This can be created from a different PC as long as we have the correct permissions and access to the port EJD is exposed to.
NOTE: Replace <username>
in the command and enter your password when prompted.
ejcli create-builder --server http://localhost:3000 --username <username>
...
export EJB_ID=<builder_id>
export EJB_TOKEN=<builder_token>
The EJB_ID
and EJB_TOKEN
provided allow us to connect our EJB instance to EJD.
Again, this connection will use the HTTP(s) interface.
Step 5: Connecting EJB to EJD
Export the two environment variables that we got from the last command and launch EJB:
export EJB_ID=<builder_id>
export EJB_TOKEN=<builder_token>
ejb --config ~/ej-workspace/config.toml connect --server http://localhost:3000
You should see that the websocket connection was established successfully.
2025-07-11T11:57:30.191943Z INFO ejb::connection: WebSocket connection established
Once we start a connection, EJB will wait until a new job request comes from EJD.
Step 6: Dispatch your first build job
Every job that can be dispatched through EJD is associated with a specific git commit hash. This allows you to later check every job associated with a specific commit.
EJB will automatically check out the new version for you before building and running your application.
When dispatching a job we need to provide:
- The commit hash associated with the current job.
- The remote URL associated with this commit.
- This allows us to run jobs from forks of our repository for instance.
- The timeout time in seconds after which the job will be cancelled.
Optionally, if you have private repositories and don't want to set up an ssh
key in the machine hosting our builder, you may also provide a token that would allow git to fetch our private repository using https
.
Once again, ej-cli
can be used to test your setup and making sure everything is working correctly.
Since our test application kmer
is publicly available we don't need to provide a token:
ejcli dispatch-build \
--socket ~/ejd-deployment/ejd/tmp/ejd.sock \
--seconds 20 \
--commit-hash eb7c6cbe6249aff4df82455bbadf4898b0167d09 \
--remote-url https://github.com/embj-org/kmer
We should see something like this as a result:
=======================================
Build finished successfully with 4 log entries:
=======================================
d2a5ae66-5eab-493e-847c-af21a52455d6 - k-mer-original [arm64,kmer unoptimized]
=======================================
From https://github.com/embj-org/kmer
* [new branch] main -> ejupstream/main
HEAD is now at eb7c6cb feat: add infinite loop example
-- Configuring done (0.0s)
-- Generating done (0.0s)
-- Build files have been written to: /home/andre/ej-workspace/kmer/build-pi
[ 25%] Built target infinite-loop
[ 50%] Built target k-mer
[100%] Built target k-mer-original
[100%] Built target k-mer-omp
=======================================
5a5df2de-9854-45b6-a4a5-0af58959a04b - k-mer-omp [arm64,kmer multi-threaded optimized]
=======================================
From https://github.com/embj-org/kmer
* [new branch] main -> ejupstream/main
HEAD is now at eb7c6cb feat: add infinite loop example
-- Configuring done (0.0s)
-- Generating done (0.0s)
-- Build files have been written to: /home/andre/ej-workspace/kmer/build-pi
[ 75%] Built target k-mer
[ 75%] Built target k-mer-original
[ 75%] Built target k-mer-omp
[100%] Built target infinite-loop
=======================================
ffdc80b8-6e59-42e7-af82-3eef5d5ba6bd - k-mer [arm64,kmer optimized]
=======================================
From https://github.com/embj-org/kmer
* [new branch] main -> ejupstream/main
HEAD is now at eb7c6cb feat: add infinite loop example
-- Configuring done (0.0s)
-- Generating done (0.0s)
-- Build files have been written to: /home/andre/ej-workspace/kmer/build-pi
[100%] Built target k-mer-omp
[100%] Built target k-mer-original
[100%] Built target k-mer
[ 62%] Built target infinite-loop
=======================================
aff0ed67-92aa-4011-876b-bf79c51a7210 - infinite-loop [arm64,infinite-loop]
=======================================
From https://github.com/embj-org/kmer
* [new branch] main -> ejupstream/main
HEAD is now at eb7c6cb feat: add infinite loop example
-- Configuring done (0.0s)
-- Generating done (0.0s)
-- Build files have been written to: /home/andre/ej-workspace/kmer/build-pi
[ 50%] Built target k-mer-omp
[ 50%] Built target k-mer-original
[ 75%] Built target infinite-loop
[100%] Built target k-mer
=======================================
Understanding the internals
Congratulations, we have now seen how we can dispatch a new job to EJD that gets picked up by EJB and is run on our target board.
In our specific use case, we have one builder instance with one board connected to it but by now you should have a good understanding of how this whole setup expands to multiple builders and boards.
Job Cancellation and Timeouts
We can also use the dispatch-run
command to build and run our application like we've done before.
The process is very similar to the dispatch-build
command but it will also run the application on the board and wait for it to finish.
ejcli dispatch-run \
--socket ~/ejd-deployment/ejd/tmp/ejd.sock \
--seconds 20 \
--commit-hash eb7c6cbe6249aff4df82455bbadf4898b0167d09 \
--remote-url https://github.com/embj-org/kmer
In this case, since we still have our infinite-loop
application being deployed, the job will eventually time out after 20 seconds.
Analyzing the results we'll be able to see 4 log entries for the 4 configs but only 3 result entries as the last config never actually produced any results before the job got cancelled.
This timeout feature relies on the EJ Builder SDK presented in the last guide.
The same way EJD sent the Build
command in the above example, after the timeout is reached, EJD will send a Cancel
command to the builder.
The builder will then notify the script performing the build that the job was cancelled. The Builder SDK will receive this cancellation command
and run the cleanup code that provided in our ejkmer-builder
application.
In cases we don't use the Builder SDK, the builder will eventually kill the process without giving it a chance to clean up, this is why we highly recommend using the Builder SDK for all our builds.
Next Steps
Congratulations! You have successfully set up an EJ Dispatcher and connected your first builder. This is a significant step towards building a scalable and manageable testing infrastructure.
You may have noticed that we haven't yet covered how to actually parse the results of the job and how to fetch results from previous jobs. EJ provides two solutions for this:
- The first solution is to directly do it inside our
ejkmer-builder
application. This is a good approach if we don't need any history of the previous results and only care about what the current job produced. - The second solution is to use the EJ Dispatcher SDK to create a custom CLI tool that can interact with EJD programmatically.
The same way the
ejcli
tool allows us to interact with EJD, we can create our own custom CLI tool that can submit jobs, query their status, and retrieve results. Additionally, we're free to implement any custom logic we need to parse and analyze the results.
In Guide 04 - Dispatcher SDK, we'll create a custom CLI tool that can:
- Submit jobs programmatically
- Query job status and results
- Parse and analyze test data
This SDK-based approach enables powerful automation and integration possibilities.
Production Tip: When deploying EJD to an open network, always use HTTPS with a reverse proxy like Traefik or Nginx to secure our dispatcher. This prevents unauthorized access and ensures encrypted communication between builders and the dispatcher. A skeleton example using Traefik is available in the ejd-deployment repository
Guide 04: Building custom tools with the Dispatcher SDK
You can find the SDK's documentation in crates.io
Overview
In the previous guide, we successfully set up an EJ Dispatcher and connected builders to create a centralized testing infrastructure. While we can now submit jobs and see results through the ejcli
tool, we haven't yet explored how to programmatically interact with the dispatcher or analyze the results it produces.
This guide demonstrates how to use the EJ Dispatcher SDK to build custom applications that can submit jobs, retrieve results, and perform automated analysis. We'll create a Rust application that connects to our dispatcher, fetches test results, and validates that different configurations of our embedded application produce consistent outputs.
By the end of this guide, we'll understand how to build custom tooling around EJ's dispatcher infrastructure, enabling powerful automation and analysis workflows for our embedded testing pipeline.
Prerequisites
Before starting this guide, ensure you have:
- Completed Guide 03: This guide builds directly on the EJ Dispatcher setup from the previous guide
- Working EJ Dispatcher setup: Including:
- EJD running and accessible
- At least one builder connected and working
- Successful job submissions using
ejcli
- The
kmer
project results from previous guides
- Rust toolchain installed: We'll need
cargo
and the Rust compiler for building the SDK application- Install via rustup.rs if you haven't already
- Basic Rust knowledge
Step 1: Setting up your rust project
Let's create a custom CLI tool called ejkmer-dispatcher
that will interact with EJD to submit jobs, retrieve results, and perform analysis on the results produced by the kmer
project.
cd ~/ej-workspace
cargo init --bin ejkmer-dispatcher
cd ejkmer-dispatcher
cargo add ej-dispatcher-sdk
cargo add ej-config
cargo add clap -F derive
cargo add tokio -F macros -F rt-multi-thread
Step 2: Using the dispatcher sdk to start a new job
The dispatcher_sdk
offers you a function to dispatch new jobs easily:
#![allow(unused)] fn main() { use ej_dispatcher_sdk::prelude::*; async fn do_run( socket: PathBuf, seconds: u64, commit_hash: String, remote_url: String, ) -> Result<()> { let job_result = ej_dispatcher_sdk::dispatch_run( &socket, commit_hash, remote_url, None, Duration::from_secs(seconds), ) .await?; println!("{}", job_result); } }
The dispatch_run
function will connect to EJD using the Unix Socket and maintain a connection until the job either finishes or is cancelled.
The job can either be immediately dispatched or put into a queue if there are already running jobs. Additionally, the jobs can be cancelled if, by the time the job leaves the queue there are no builders available or if the job times out.
Once we get to this line :
#![allow(unused)] fn main() { println!("{}", job_result); }
We know the job has finished successfully and we're ready to start parsing the results.
Step 3: Parse the results
You may be wondering what the type of job_result
is. If so, here it goes:
#![allow(unused)] fn main() { /// Run operation result. #[derive(Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct EjRunResult { /// Run logs per board configuration. pub logs: Vec<(EjBoardConfigApi, String)>, /// Run results per board configuration. pub results: Vec<(EjBoardConfigApi, String)>, /// Whether the run was successful. pub success: bool, } }
We're mostly interested in the results: Vec<(EjBoardConfigApi, String)>
which is a dynamic array of Board Config
and String
pairs.
- The
EjBoardConfigApi
holds the config ID, name and tags - The
String
holds the job results. For our specific use case eachString
will have the following format. This is the content of theresults_path
when therun_script
finishes.
The logs
are useful to see what happened between every job phase, checkout
, build
and run
and they're are printed automatically when doing this:
#![allow(unused)] fn main() { println!("{}", job_result); }
For our example, the results follow this format:
Results:
ABC: 2
BCD: 1
CDA: 1
DAB: 1
real 0m0.005s
user 0m0.000s
sys 0m0.005s
We'll focus on parsing the sequences found and their occurrences, we'll then check to make sure that every config finds the same sequences and the same number of occurrences per sequence.
Here's an example of a function that does just that:
#![allow(unused)] fn main() { struct ConfigResult { config: EjBoardConfigApi, data: HashMap<String, usize>, } fn parse_results(job_result: &EjRunResult) -> Vec<ConfigResult> { let mut parsed_results: Vec<ConfigResult> = Vec::new(); for (board_config, result) in job_result.results.iter() { let mut occurrences_map: HashMap<String, usize> = HashMap::new(); let mut found_start_of_results = false; for line in result.lines() { if line.contains("Results:") { found_start_of_results = true; continue; } if !found_start_of_results { continue; } if line.contains(':') { let splitted: Vec<&str> = line.split(": ").collect(); assert_eq!(splitted.len(), 2); let sequence = splitted[0]; let n_occurences = splitted[1] .parse() .expect("Expected number on right side of ':'"); occurrences_map.insert(sequence.to_string(), n_occurences); } } parsed_results.push(ConfigResult { config: board_config.clone(), data: occurrences_map, }); } parsed_results } }
Step 4: Check the results
Once we have the results parsed, it makes it easier to reason with the code that actually checks that the results are valid:
#![allow(unused)] fn main() { fn check_results(parsed_results: &Vec<ConfigResult>) { for i in 0..parsed_results.len() { for j in (i + 1)..parsed_results.len() { let config_i = &parsed_results[i].config; let config_j = &parsed_results[j].config; let data_i = &parsed_results[i].data; let data_j = &parsed_results[j].data; assert_eq!( data_i.len(), data_j.len(), "Different number of sequences for {} and {} {} vs {}", config_i.name, config_j.name, data_i.len(), data_j.len(), ); for (sequence, expected) in parsed_results[i].data.iter() { let actual = data_j.get(sequence); assert!( actual.is_some(), "Couldn't find {} in {}", sequence, config_j.name ); let actual = actual.unwrap(); assert_eq!( expected, actual, "Expected {} occurrences for {}. Got {} ", expected, sequence, actual ); } } } } }
Step 5: Completing our do_run
function
Once we can parse and check the results, the only thing left to do is to use these functions to make sure our run was successful:
#![allow(unused)] fn main() { async fn do_run( socket: PathBuf, seconds: u64, commit_hash: String, remote_url: String, ) -> Result<()> { let job_result = ej_dispatcher_sdk::dispatch_run( &socket, commit_hash, remote_url, None, Duration::from_secs(seconds), ) .await?; println!("{}", job_result); if !job_result.success { return Err(Error::RunError); } let parsed_results = parse_results(&job_result); check_results(&parsed_results); println!("Results OK!"); Ok(()) } }
Step 6: Add a CLI interface
We'll also add a basic CLI interface that will make it easier to add new commands down the line.
For this we'll use clap
that we've already added to our project.
The following code should be pretty straight forward to reason with:
#![allow(unused)] fn main() { use clap::{Parser, Subcommand}; #[derive(Parser)] #[command(name = "ejkmer-dispatcher")] #[command(about = "EJ Kmer Dispatcher - Job dispatcher and result handler for the Kmer project")] struct Cli { #[command(subcommand)] pub command: Commands, } #[derive(Subcommand)] enum Commands { DispatchRun { /// Path to the EJD's unix socket #[arg(short, long)] socket: PathBuf, /// The maximum job duration in seconds #[arg(long)] seconds: u64, /// Git commit hash #[arg(long)] commit_hash: String, /// Git remote url #[arg(long)] remote_url: String, }, } }
Step 7: Putting everything together
#[tokio::main] async fn main() -> Result<()> { let cli = Cli::parse(); match cli.command { Commands::DispatchRun { socket, seconds, commit_hash, remote_url, } => do_run(socket, seconds, commit_hash, remote_url).await, } }
clap
will automatically parse the arguments for you to make sure that everything works correctly.
We can now try our new application with the same arguments as the ej-cli
Step 8: Try it out
First, remove the infinite-loop
config as it doesn't do anything useful.
Remove this entry from ~/ej-workspace/config.toml
:
[[boards.configs]]
name = "infinite-loop"
tags = ["arm64", "infinite-loop"]
build_script = "ejkmer-builder"
run_script = "ejkmer-builder"
results_path = "/home/<user>/ej-workspace/results_infinite-loop.txt"
library_path = "/home/<user>/ej-workspace/kmer"
And now run our new program:
cd ~/ej-workspace/ejkmer-dispatcher/
cargo run -- dispatch-run \
--socket ~/ejd-deployment/ejd/tmp/ejd.sock \
--seconds 60 \
--commit-hash eb7c6cbe6249aff4df82455bbadf4898b0167d09 \
--remote-url https://github.com/embj-org/kmer
=======================================
Run finished successfully with 3 log entries:
=======================================
84b19f1e-66c1-4182-a428-c4357be4d9a4 - k-mer [arm64,kmer optimized]
=======================================
From https://github.com/embj-org/kmer
* [new branch] main -> ejupstream/main
HEAD is now at eb7c6cb feat: add infinite loop example
-- Configuring done (0.0s)
-- Generating done (0.0s)
-- Build files have been written to: /home/andre/ej-workspace/kmer/build-pi
[ 50%] Built target infinite-loop
[ 50%] Built target k-mer-original
[ 75%] Built target k-mer-omp
[100%] Built target k-mer
Results:
ABC: 2
BCD: 1
CDA: 1
DAB: 1
real 0m0.003s
user 0m0.003s
sys 0m0.000s
=======================================
51ba26d0-b71e-444a-9732-9a33e51dd4dd - k-mer-omp [arm64,kmer multi-threaded optimized]
=======================================
From https://github.com/embj-org/kmer
* [new branch] main -> ejupstream/main
HEAD is now at eb7c6cb feat: add infinite loop example
-- Configuring done (0.0s)
-- Generating done (0.0s)
-- Build files have been written to: /home/andre/ej-workspace/kmer/build-pi
[ 50%] Built target k-mer-original
[ 50%] Built target infinite-loop
[100%] Built target k-mer-omp
[100%] Built target k-mer
Results:
ABC: 2
BCD: 1
CDA: 1
DAB: 1
real 0m0.004s
user 0m0.004s
sys 0m0.003s
=======================================
734cdfb0-85aa-4405-af5a-752d68f5c003 - k-mer-original [arm64,kmer unoptimized]
=======================================
From https://github.com/embj-org/kmer
* [new branch] main -> ejupstream/main
HEAD is now at eb7c6cb feat: add infinite loop example
-- Configuring done (0.0s)
-- Generating done (0.0s)
-- Build files have been written to: /home/andre/ej-workspace/kmer/build-pi
[ 75%] Built target k-mer-original
[ 75%] Built target k-mer
[ 75%] Built target infinite-loop
[100%] Built target k-mer-omp
Results:
ABC: 2
BCD: 1
CDA: 1
DAB: 1
real 0m0.003s
user 0m0.003s
sys 0m0.000s
=======================================
=======================================
Run finished successfully with 3 result entries:
=======================================
734cdfb0-85aa-4405-af5a-752d68f5c003 - k-mer-original [arm64,kmer unoptimized]
=======================================
Results:
ABC: 2
BCD: 1
CDA: 1
DAB: 1
real 0m0.005s
user 0m0.005s
sys 0m0.001s
=======================================
84b19f1e-66c1-4182-a428-c4357be4d9a4 - k-mer [arm64,kmer optimized]
=======================================
Results:
ABC: 2
BCD: 1
CDA: 1
DAB: 1
real 0m0.005s
user 0m0.000s
sys 0m0.005s
=======================================
51ba26d0-b71e-444a-9732-9a33e51dd4dd - k-mer-omp [arm64,kmer multi-threaded optimized]
=======================================
Results:
ABC: 2
BCD: 1
CDA: 1
DAB: 1
real 0m0.007s
user 0m0.000s
sys 0m0.010s
=======================================
Results OK!
Seeing Results OK!
means that the job ran succesfully and the results were as expected !
What's Next
Congratulations! You've now built a complete embedded testing infrastructure with EJ, from basic builder setup to advanced dispatcher integration with custom analysis tools. However, this is just the beginning of what's possible with EJ.
Beyond Simple Applications
Throughout these guides, we've used the kmer
application as our example - a relatively simple C program that processes text files.
The real power of EJ becomes apparent when we apply it to more complex embedded applications:
- Multi-component systems: Applications with multiple executables, libraries, and configuration files
- Real-time systems: Programs that interact with hardware peripherals, sensors, or communication protocols
- Performance-critical applications: Code that needs to be tested across different optimization levels and compiler flags
- Cross-platform applications: Software that must run on multiple embedded architectures (ARM, RISC-V, x86 embedded, etc.)
Since EJ works with any application that can be built and deployed without manual intervention, we can integrate virtually any embedded project. The key is writing appropriate build and deployment scripts (whether shell scripts or using the Builder SDK) that handle our specific application's requirements.
Advanced Result Analysis and Integration
Our result validation example simply checked that different configurations produced identical outputs. In real-world scenarios, we can implement much more sophisticated analysis and integration workflows:
Communication and Notifications
- Slack integration: Send notifications when tests complete, fail, or show performance regressions
- Email reports: Generate detailed test summaries and email them to your team
- GitHub/GitLab PR comments: Automatically comment on pull requests with test results and performance metrics
Continuous Integration Workflows
- Performance trend analysis: Compare current results with historical data to detect regressions
- Automated benchmarking: Track performance metrics over time and alert on significant changes
- Cross-platform validation: Ensure our application behaves consistently across different hardware platforms
Result Presentation and Documentation
- HTML report generation: Create rich, interactive web pages showing test results with charts and graphs
- Dashboard creation: Build real-time dashboards showing the health of your embedded systems
- Automated documentation: Generate performance reports and system specifications based on test results
Final Thoughts
EJ transforms embedded testing from a manual, error-prone process into a reliable, automated pipeline. By centralizing job management, providing proper cancellation handling, and offering programmatic access to results, EJ enables the same level of testing sophistication in embedded development that web developers take for granted.
The journey from a simple shell script to a full distributed testing infrastructure demonstrates how EJ scales with our needs - start simple, then add complexity as your requirements grow. From a solo developer with a single Raspberry Pi to a company managing hundreds of embedded devices, EJ provides the tools to build a testing infrastructure that grows with your project.
Happy testing!
Community: Share your custom tools and integrations with the EJ community to help others solve similar challenges.