4.4 Develop in a Container

Note

This section applies only to X2 Ultra and X2 Ultra(new version). X2 EDU has no development computing unit, so this section does not apply.

Use containers to isolate the development environment, avoid polluting the robot’s main system, and support multiple SDK versions side by side.

The container bind-mounts the host’s (i.e. the development computing unit’s) SDK directory directly. Edits on the development computing unit are synced into the container in real time, and build artifacts are synced back.

Attention

When developing on the development computing unit, from v1.2.0 on secondary development programs must be built and run inside a container. Non-container development mode (using the SDK directly on the development computing unit host) will have restricted permissions, and system package installation (apt, pip, etc.) will only be supported in container-based development mode.

Both modes are available in v1.1.0, which is a transitional release. If your secondary development programs still run directly on the development computing unit host, complete the migration during v1.1.0 by following this chapter:

  1. Build the image and start the container as described below.

  2. Put your secondary development source code under the SDK directory /home/agi/aimdk (this directory is mounted into the container, so the host and the container share the same files).

  3. Rebuild the SDK and your secondary development programs inside the container, and move any dependencies you installed manually on the host into the Dockerfile or install them inside the container.

  4. Run inside the container and confirm the behavior matches non-container mode.


4.4.1 Prepare the Files

Create a directory on the development computer for the image-related scripts, e.g. ~/aimdk-image/:

mkdir -p ~/aimdk-image
cd ~/aimdk-image

Save the two files below into that directory manually.

Pre-Build Check: Mirror Reachability

The Dockerfile below uses mirrors hosted in China throughout. Before building, confirm that all four lines return 200:

for u in https://ngc.nju.edu.cn/v2/nvidia/l4t-base/tags/list \
         https://mirrors.aliyun.com/ubuntu-ports/dists/jammy/Release \
         https://mirrors.aliyun.com/ros2/ubuntu/dists/jammy/Release \
         https://pypi.tuna.tsinghua.edu.cn/simple/ ; do
  printf "%-58s %s\n" "$(echo "$u" | cut -c1-56)" \
    "$(curl -s -o /dev/null -w '%{http_code}' -m 15 "$u")"
done

Pre-Build Check: Trailing Whitespace in /etc/subgid

Rootless podman relies on /etc/subgid to authorize GID mapping when creating containers. If a line has trailing whitespace, the parser discards the entire line and that GID authorization is lost. The error message names only the GID (e.g. gid range ... -> [1000-1001) not allowed), which is highly misleading. It often reappears after switching machines or reflashing, so check before building:

cat -A /etc/subgid

Look for lines with a space or tab before $. A correct line reads agi:1000:1$ (1 immediately followed by $, with no space). If you see a bad line like agi:1000:1 $, fix it:

sudo sed -i 's/[[:space:]]*$//' /etc/subgid
cat -A /etc/subgid   # Recheck; the bad line should become agi:1000:1$
podman system migrate

Then continue with the build.

Dockerfile

Save the following as ~/aimdk-image/Dockerfile. You can write it directly with the cat command below (the EOF must be quoted so that $(...) is not expanded prematurely):

cat > ~/aimdk-image/Dockerfile <<'EOF'
# Pull the base image via the NJU NGC mirror; nvcr.io is often unreachable in China.
FROM ngc.nju.edu.cn/nvidia/l4t-base:r36.2.0

LABEL agi.image.purpose="ROS Humble base, no CUDA bundled (mount /usr/local/cuda + /usr/lib/.../libcudnn,libnvinfer at runtime)"
LABEL agi.image.base="l4t-base:r36.2.0"
LABEL agi.image.note="Mount /opt/ros, CUDA toolkit, cuDNN/TensorRT shared libs from host at runtime"

ENV DEBIAN_FRONTEND=noninteractive
ENV TZ=Asia/Shanghai

# Point the container's system apt at the Aliyun ubuntu-ports mirror: the base image
# defaults to ports.ubuntu.com, which is slow in China, and the hundreds of megabytes
# of build-essential / cmake / OpenCV dependencies below all come from this source.
# Also disable the NVIDIA L4T apt source; this image installs no l4t packages.
RUN for f in /etc/apt/sources.list /etc/apt/sources.list.d/*.list /etc/apt/sources.list.d/*.sources; do \
        [ -f "$f" ] || continue; \
        sed -i -E \
            -e 's@https?://ports\.ubuntu\.com/ubuntu-ports@https://mirrors.aliyun.com/ubuntu-ports@g' \
            -e 's@https?://(archive|security)\.ubuntu\.com/ubuntu@https://mirrors.aliyun.com/ubuntu@g' \
            "$f"; \
    done && \
    if [ -f /etc/apt/sources.list.d/nvidia-l4t-apt-source.list ]; then \
        sed -i -E 's@^[[:space:]]*deb@# deb@' /etc/apt/sources.list.d/nvidia-l4t-apt-source.list; \
    fi && \
    grep -rhE '^[[:space:]]*deb ' /etc/apt/sources.list /etc/apt/sources.list.d/ 2>/dev/null

# Install ROS 2 apt source and ros-humble-ros-core to pull in system-level
# dependencies (libspdlog1, libtinyxml2-9, python3-packaging, etc.).
# /opt/ros is overridden by a host bind-mount at runtime.
# The ROS apt source uses the Aliyun mirror, reachable from within China.
# The genuine ROS 2 GPG key is fetched by fingerprint F42ED6FBAB17C654 from a keyserver;
# do NOT use mirrors.aliyun.com/ros/ros.key (it lacks the ROS 2 key and causes NO_PUBKEY).
# Before using a keyserver, install dirmngr and create /root/.gnupg (absent in the base
# image), otherwise gpg fails with "No dirmngr".
RUN apt-get update && \
    apt-get install -y --no-install-recommends \
        curl gnupg2 dirmngr lsb-release ca-certificates && \
    mkdir -p /root/.gnupg && chmod 700 /root/.gnupg && \
    echo "standard-resolver" >> /root/.gnupg/dirmngr.conf && \
    echo "disable-ipv6" >> /root/.gnupg/dirmngr.conf && \
    gpg --no-default-keyring --keyring /usr/share/keyrings/ros-archive-keyring.gpg \
        --keyserver hkp://keyserver.ubuntu.com:80 --recv-keys F42ED6FBAB17C654 && \
    echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/ros-archive-keyring.gpg] https://mirrors.aliyun.com/ros2/ubuntu $(lsb_release -cs) main" \
        > /etc/apt/sources.list.d/ros2.list && \
    apt-get update && \
    apt-get install -y --no-install-recommends ros-humble-ros-core && \
    apt-get install -y --no-install-recommends \
        usbutils iproute2 tini python3-pip \
        build-essential binutils git \
        python3-opencv python3-numpy \
        libjpeg-dev libpng-dev libtiff-dev \
        libavcodec-dev libavformat-dev libavutil-dev libswscale-dev \
        libgtk-3-dev \
        openssh-client \
        libboost-python1.74.0 \
        bluez \
        cmake ninja-build \
        python3-colcon-common-extensions \
        libcurl4-openssl-dev \
        libncurses-dev \
        pkg-config && \
    printf '%s\n' \
        '[global]' \
        'index-url = https://pypi.tuna.tsinghua.edu.cn/simple' \
        'extra-index-url = https://mirrors.aliyun.com/pypi/simple/' \
        '                  https://mirrors.cloud.tencent.com/pypi/simple/' \
        'timeout = 60' \
        'retries = 5' \
        > /etc/pip.conf && \
    pip3 install --no-cache-dir -U pip packaging && \
    pip3 install --no-cache-dir "scikit-build-core<0.10" nanobind && \
    pip3 install --no-cache-dir --no-build-isolation ruckig==0.15.3 && \
    cd /opt && \
    ( timeout 900 git clone --depth 1 -b 4.8.0 https://gitee.com/mirrors/opencv.git opencv-4.8.0 || \
      ( echo "gitee clone failed, falling back to a GitHub mirror in China" && rm -rf /opt/opencv-4.8.0 && \
        curl -fSL --retry 3 --retry-delay 5 -m 1800 -o /tmp/opencv-4.8.0.tar.gz \
             https://gh-proxy.com/https://github.com/opencv/opencv/archive/refs/tags/4.8.0.tar.gz && \
        tar -xzf /tmp/opencv-4.8.0.tar.gz -C /opt && rm -f /tmp/opencv-4.8.0.tar.gz ) ) && \
    cd /opt/opencv-4.8.0 && mkdir -p build && cd build && \
    cmake -DCMAKE_BUILD_TYPE=Release \
          -DCMAKE_INSTALL_PREFIX=/usr/local \
          -DBUILD_PYTHON_SUPPORT=OFF \
          -DBUILD_EXAMPLES=OFF -DBUILD_TESTS=OFF -DBUILD_PERF_TESTS=OFF \
          -DWITH_GTK=ON \
          -DBUILD_LIST=core,imgproc,imgcodecs,videoio,highgui,calib3d,features2d,photo,video,objdetect \
          -DOPENCV_GENERATE_PKGCONFIG=ON .. && \
    make -j$(nproc) && make install && \
    rm -rf /opt/opencv-4.8.0 && \
    rm -rf /var/lib/apt/lists/* /var/cache/apt/archives/*

# Auto-source the host-mounted ROS setup in interactive shells.
RUN echo 'if [ -f /opt/ros/humble/setup.bash ]; then source /opt/ros/humble/setup.bash; fi' \
        >> /etc/bash.bashrc

# Make the dynamic linker aware of the host-mounted CUDA paths.
RUN echo "/usr/local/cuda/lib64"        > /etc/ld.so.conf.d/cuda.conf && \
    echo "/usr/local/cuda/targets/aarch64-linux/lib" >> /etc/ld.so.conf.d/cuda.conf

WORKDIR /home/agi

# tini handles PID 1 signal forwarding.
ENTRYPOINT ["/usr/bin/tini", "--"]
CMD ["sleep", "infinity"]
EOF

run.sh

Save the following as ~/aimdk-image/run.sh and make it executable with chmod +x run.sh. You can write it directly with the cat command below:

cat > ~/aimdk-image/run.sh <<'EOF'
#!/bin/bash
# Launch agi/aimdk-dev:dev with host ROS, CUDA, cuDNN/TRT and aima mounted in.
set -euo pipefail

IMAGE="${IMAGE:-agi/aimdk-dev:dev}"
NAME="${NAME:-aimdk-dev}"

# ---- helper: only add a -v mount if the host source exists ----
# Usage: m <src> <dst> [opts:ro|rw|rslave]   (default ro)
MOUNTS=()
m() {
  local src="$1" dst="$2" opts="${3:-ro}"
  if [ -e "$src" ]; then
    MOUNTS+=("-v" "$src:$dst:$opts")
  else
    echo "  mount $src NOT present, skipping" >&2
  fi
}

# Host GIDs to add to the container (groups owning device nodes).
HOST_GIDS=(
  20    # dialout - /dev/ttyUSB*
  29    # audio   - /dev/snd
  44    # video   - /dev/nvmap, /dev/nvhost-* (required by CUDA)
  106   # input   - /dev/input/event*
  109   # render  - /dev/dri/renderD*
  995   # debug   - /dev/nvhost-dbg-*
  1000  # run     - /agibot/data/var/MapManagerModule
)

# Translate host GIDs to container-side GIDs via the rootless ID mapping.
declare -a GROUP_ADDS
MAP_RAW=$(podman info --format '{{.Host.IDMappings.GIDMap}}')
for HG in "${HOST_GIDS[@]}"; do
  CG=$(echo "$MAP_RAW" | grep -oE '\{[0-9]+ '"$HG"' [0-9]+\}' | head -1 \
       | grep -oE '^\{[0-9]+' | tr -d '{')
  if [ -z "$CG" ]; then
    echo "GID $HG not mapped in /etc/subgid, skipping" >&2
  else
    GROUP_ADDS+=("--group-add" "$CG")
    echo "  $HG (host) -> $CG (container)" >&2
  fi
done

# Enumerate userspace CUDA shared libraries to bind-mount (cuDNN, TensorRT, cuBLAS, cuFFT, ...).
for f in /usr/lib/aarch64-linux-gnu/libcudnn*.so* \
         /usr/lib/aarch64-linux-gnu/libnvinfer*.so* \
         /usr/lib/aarch64-linux-gnu/libnvonnxparser*.so* \
         /usr/lib/aarch64-linux-gnu/libnvparsers*.so* \
         /usr/lib/aarch64-linux-gnu/libnvcaffe_parser*.so* \
         /usr/lib/aarch64-linux-gnu/libcublas*.so* \
         /usr/lib/aarch64-linux-gnu/libcufft*.so* \
         /usr/lib/aarch64-linux-gnu/libcurand*.so* \
         /usr/lib/aarch64-linux-gnu/libcusolver*.so* \
         /usr/lib/aarch64-linux-gnu/libcusparse*.so* \
         /usr/lib/aarch64-linux-gnu/libcupti*.so* \
         /usr/lib/aarch64-linux-gnu/libnpp*.so* \
         /usr/lib/aarch64-linux-gnu/libnvjpeg*.so* \
         /usr/lib/aarch64-linux-gnu/libnvToolsExt*.so* \
         /usr/lib/aarch64-linux-gnu/libnvrtc*.so* ; do
  m "$f" "$f"
done
echo "  CUDA libs to bind-mount: $(ls /usr/lib/aarch64-linux-gnu/libcuda*.so* 2>/dev/null | wc -l) files" >&2

# ncurses dev files (headers, link-time .so symlinks, pkg-config) for the keyboard example.
for f in /usr/include/curses.h /usr/include/ncurses*.h /usr/include/term.h \
         /usr/include/termcap.h /usr/include/unctrl.h /usr/include/eti.h \
         /usr/lib/aarch64-linux-gnu/libncurses*.so* /usr/lib/aarch64-linux-gnu/libtinfo*.so* \
         /usr/lib/aarch64-linux-gnu/pkgconfig/ncurses*.pc /usr/lib/aarch64-linux-gnu/pkgconfig/tinfo*.pc; do
  m "$f" "$f"
done

# Python include dirs (numpy, ...) that the host /opt/ros CMake configs reference by absolute path.
while read -r d; do
  m "$d" "$d"
done < <(grep -rhoE '/[A-Za-z0-9_./+-]*(dist|site)-packages/[A-Za-z0-9_./+-]*include' \
           --include='*.cmake' /opt/ros/humble/share 2>/dev/null | sort -u)

# Extract AGIBOT_* env vars from the firmware-managed bashrc into an env-file.
AGIBOT_BASHRC="/home/agi/.aima/env/bashrc"
ENV_FILE=$(mktemp /tmp/aimdk-env.XXXXXX)
trap 'rm -f "$ENV_FILE"' EXIT
if [ -r "$AGIBOT_BASHRC" ]; then
  sed -nE "s/^export (AGIBOT_[A-Z_]+)='(.*)'\$/\1=\2/p" "$AGIBOT_BASHRC" > "$ENV_FILE"
  echo "  AGIBOT env vars: $(wc -l < "$ENV_FILE") loaded from $AGIBOT_BASHRC" >&2
else
  echo "  $AGIBOT_BASHRC not readable, skipping AGIBOT_* env" >&2
fi

# Remove any existing container with the same name.
if podman ps -a --format '{{.Names}}' | grep -qx "$NAME"; then
  echo "stopping & removing old $NAME..."
  podman rm -f "$NAME" >/dev/null
fi

# If the DDS config exported by the firmware exists, mount it at the container path
# referenced by FASTRTPS_DEFAULT_PROFILES_FILE (shared discovery with the robot).
m /home/agi/.aima/env/ros_dds_configuration.xml /etc/ros_dds_configuration.xml

# ---- single fixed mount: the SDK dir is always present and must be rw ----
m /home/agi/aimdk /home/agi/aimdk rw

echo "starting $NAME from $IMAGE..."
podman run -d --name "$NAME" \
  --ipc=host --network=host --pid=host \
  --device nvidia.com/gpu=all \
  "${GROUP_ADDS[@]}" \
  -e HOME=/home/agi \
  -e FASTRTPS_DEFAULT_PROFILES_FILE=/etc/ros_dds_configuration.xml \
  -e PATH=/opt/ros/humble/bin:/usr/local/cuda/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin \
  -e LD_LIBRARY_PATH=/usr/local/lib:/usr/local/cuda/lib64:/opt/ros/humble/lib:/host-libs/aarch64-linux-gnu \
  --env-file "$ENV_FILE" \
  -v /etc/machine-id:/etc/machine-id:ro \
  -v /usr/lib/aarch64-linux-gnu:/host-libs/aarch64-linux-gnu:ro \
  -v /opt/ros:/opt/ros:ro \
  -v /usr/local/bin/aima:/usr/local/bin/aima:ro \
  -v /dev:/dev:rslave \
  -v /run/dbus:/run/dbus:rslave \
  "${MOUNTS[@]}" \
  "$IMAGE"
EOF

4.4.2 Build the Image

cd ~/aimdk-image
podman build -t agi/aimdk-dev:dev .

4.4.3 Start the Container

cd ~/aimdk-image
./run.sh

The script automatically:

  • Removes any existing container with the same name

  • Translates host groups (dialout, audio, video, input, render, debug) to container-side GIDs and adds them via --group-add

  • Mounts /opt/ros, the CUDA toolkit, and cuDNN/TensorRT shared libraries

  • Mounts /dev, providing access to USB, sound card, input devices, and external storage

  • Mounts the SDK directory /home/agi/aimdk to the same path inside the container (read-write)

  • Mounts the DDS config /home/agi/.aima/env/ros_dds_configuration.xml, if present, to /etc/ros_dds_configuration.xml inside the container (shared topic discovery with the robot)

  • Mounts the remaining host paths (CUDA libraries, ncurses development files, etc.) only if they exist (the m() helper), skipping missing ones automatically so the script is portable across devices

  • Starts a long-running container aimdk-dev that shares namespaces with the host via --ipc=host --network=host --pid=host

Note

The default image name is agi/aimdk-dev:dev and container name is aimdk-dev. Override them via the IMAGE / NAME environment variables:

IMAGE=agi/aimdk-dev:dev NAME=my-dev ./run.sh

4.4.4 Enter the Container

podman exec -it aimdk-dev bash

# Interactive shells in the container automatically source /opt/ros/humble/setup.bash
ros2 topic list   # Should list the robot's topics

4.4.5 Build and Run

/home/agi/aimdk inside the container and on the host are the same files. Build inside the container with the standard workflow:

Note

If you previously built on the host, clean the old artifacts before building, otherwise the build fails with gmake: /usr/local/bin/cmake: No such file or directory (the cmake path recorded in the old artifacts does not exist inside the container):

rm -rf ~/aimdk/build ~/aimdk/install ~/aimdk/log

That directory is the same set of files on the host and inside the container, so the host side must be rebuilt after cleaning.

# This step can be skipped when not using a host PC / computing pack
source /opt/ros/humble/setup.bash

# Build the SDK (host PC users: place the SDK in ~/aimdk)
cd ~/aimdk && colcon build

# Load the build artifacts
source install/local_setup.bash

Then proceed to Running a Code Example.