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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# Keeps the Docker build context lean for test/Dockerfile (context = repo root).
# The x86 services-only test image only needs the frontend, the four in-repo manager
# services, the gateway, and test/ — not the heavy native-build submodules.
.git
**/node_modules
**/__pycache__
**/*.pyc
frontend/dist
build
out

# Native-build service submodules — not compiled or run in the x86 test harness.
services/rtsp-server
services/mavlink-router
services/polaris
services/logloader
services/dds-agent
services/rid-transmitter
services/flight-review
services/go2rtc
services/jetson-can
56 changes: 56 additions & 0 deletions test/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
# syntax=docker/dockerfile:1
#
# Local x86 test image for the ARK-OS userspace stack: the four Python managers + the
# Express gateway + nginx serving the built Vue UI. No camera, no flight controller, no
# systemd. Build context is the repo root (see test/docker-compose.yml). See test/README.md.

# --- build the Vue UI -> dist/ ------------------------------------------------------
FROM node:20-slim AS frontend
WORKDIR /app
COPY frontend/package.json ./
RUN npm install
COPY frontend/ ./
RUN npm run build

# --- install the gateway's production node_modules ----------------------------------
FROM node:20-slim AS gateway
WORKDIR /gw
COPY services/ark-ui-backend/package.json ./
RUN npm install --omit=dev
COPY services/ark-ui-backend/ ./

# --- runtime: python managers + node gateway + nginx + supervisord ------------------
FROM python:3.11-slim AS runtime
ENV DEBIAN_FRONTEND=noninteractive PYTHONUNBUFFERED=1
RUN apt-get update \
&& apt-get install -y --no-install-recommends nginx supervisor gcc python3-dev \
&& rm -rf /var/lib/apt/lists/* \
&& rm -f /etc/nginx/sites-enabled/default

# Manager Python deps (mirrors packaging/build_venv.sh).
RUN pip install --no-cache-dir \
pymavlink dronecan fastapi uvicorn python-multipart psutil toml pyyaml pyserial

# Node runtime + the gateway with its installed deps.
COPY --from=gateway /usr/local/bin/node /usr/local/bin/node
COPY --from=gateway /gw /usr/lib/ark-os/ark-ui-backend

# The four in-repo manager services.
COPY services/system-manager/ /usr/lib/ark-os/python/system-manager/
COPY services/service-manager/ /usr/lib/ark-os/python/service-manager/
COPY services/connection-manager/ /usr/lib/ark-os/python/connection-manager/
COPY services/autopilot-manager/ /usr/lib/ark-os/python/autopilot-manager/

# Built UI.
COPY --from=frontend /app/dist /var/www/ark-ui/html

# Harness bits: nginx site, supervisord config, fake FC, systemctl/journalctl shims.
COPY test/nginx.conf /etc/nginx/conf.d/ark-ui.conf
COPY test/supervisord.conf /etc/supervisord.conf
COPY test/mavlink_stub.py /usr/lib/ark-os/test/mavlink_stub.py
COPY test/bin/systemctl test/bin/journalctl /usr/local/bin/
RUN chmod +x /usr/local/bin/systemctl /usr/local/bin/journalctl \
&& mkdir -p /usr/lib/ark-os/manifests /etc/ark-os /run/ark-os-test

EXPOSE 80
CMD ["supervisord", "-c", "/etc/supervisord.conf"]
62 changes: 62 additions & 0 deletions test/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
# Local test harness (x86, no hardware)

Run the ARK-OS userspace stack and web UI on your dev machine — in containers, with no camera, no flight controller, and no reflash. This is for iterating on the managers and the UI; it deliberately does **not** rebuild or install the arm64 `.deb` (see *Fidelity* below).

## What runs

One **`arkos`** container (like the real device, everything on shared localhost):

- the four FastAPI managers — `system` (3004), `service` (3002), `connection` (3001), `autopilot` (3003)
- the **ark-ui-backend** Express gateway (3000)
- **nginx** (:80) serving the built Vue UI and proxying `/api` → gateway, `/video/` → go2rtc
- a **fake flight controller** (`mavlink_stub.py`) feeding heartbeats + telemetry to the autopilot manager

…plus a **`go2rtc`** sidecar whose camera is a synthetic ffmpeg test pattern, so the Video page streams over the real WebRTC path with no `/dev/video*`.

## Run it

Only Docker is required — nothing else touches your host. The runner script uses plain `docker` (no Compose plugin or buildx needed):

```bash
bash test/run.sh up # build + start; UI at http://localhost:8080
bash test/run.sh logs # follow all service logs
bash test/run.sh down # stop and remove
```

First run takes a few minutes (it builds the Vue UI and installs deps in the image); after that it's cached.

If you have the Compose plugin, `cd test && docker compose up --build` does the same thing (see `docker-compose.yml`).

## What works vs. what's faked

| Page | Status |
|------|--------|
| **System** | Real — system-manager's generic-Linux collector (CPU/mem/temp where the container can read it). |
| **Video** | Real WebRTC path; camera is a test pattern. Edit `go2rtc.test.yaml` (or via the Services page) and restart the `go2rtc` container to change the source. |
| **Autopilot** | Connected to the fake FC — heartbeat, battery, attitude, position move. No params/commands (those need SITL — see below). |
| **Services** | Driven by `manifests/`; status/start/stop/enable/logs work via the `systemctl`/`journalctl` shims (state is faked, in-container). |
| **Connections** | **Degraded** — connection-manager shells out to `nmcli`, which isn't present, so most actions error. This page is the one thing that really wants the systemd/NetworkManager tier. |

System-manager actions that write to the host (e.g. setting the hostname) will fail or no-op in the container — expected.

## Fast UI iteration

The image bakes the built UI, so a frontend change means a rebuild. For tight loops, run the Vue dev server on your host against the running backend instead:

```bash
cd frontend && npm run serve # http://localhost:8081 with HMR
```

`frontend/vue.config.js` already proxies `/api` → `localhost:3000`; add a `/video` proxy to `localhost:8080` if you need the Video page in dev mode. (The gateway's `:3000` is internal to the `arkos` container — publish it in `docker-compose.yml` with `ports: ["3000:3000"]` if your dev server needs to reach it directly.)

## Upgrading the fake FC to PX4 SITL

The stub only emits telemetry. For real parameters/commands (so the Autopilot tabs are fully exercisable), drop in PX4 SITL and point the autopilot manager at it instead of the stub:

1. Add a SITL service to the compose file, e.g. `jonasvautherin/px4-gazebo-headless`, exposing its MAVLink UDP.
2. In `supervisord.conf`, change `autopilot-manager`'s `--connection-string` to the SITL endpoint, and disable the `mavlink-stub` program.

## Notes

- A repo-root `.dockerignore` keeps the build context lean (skips `.git`, `node_modules`, and the native-build service submodules the x86 harness doesn't use).
- **Fidelity:** this runs x86-native builds of the managers/gateway, not the arm64 artifact you ship. It's for behavior/UI iteration. To validate the actual package (install, systemd units, nginx wiring) before a release, run the real `.deb` in an arm64 systemd container under qemu — a separate, heavier harness we can add when needed.
51 changes: 51 additions & 0 deletions test/bin/journalctl
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
#!/usr/bin/env python3
"""Minimal `journalctl -o json` stand-in for the no-systemd test container.

service-manager streams logs with `journalctl -u <svc> -b 0 -n N -f -o json` and parses
each line for __REALTIME_TIMESTAMP (microseconds), MESSAGE and PRIORITY. We emit a short
synthetic backlog and then, with -f, keep emitting a line every couple of seconds so the
log pane in the UI shows a live tail.
"""
import itertools
import json
import sys
import time


def emit(unit: str, message: str, priority: int = 6) -> None:
line = {
"__REALTIME_TIMESTAMP": str(int(time.time() * 1_000_000)),
"PRIORITY": str(priority),
"MESSAGE": message,
"SYSLOG_IDENTIFIER": unit,
}
sys.stdout.write(json.dumps(line) + "\n")
sys.stdout.flush()


def main() -> int:
args = sys.argv[1:]
unit, follow, n = "system", False, 50
for i, a in enumerate(args):
if a == "-u" and i + 1 < len(args):
unit = args[i + 1]
elif a == "-f":
follow = True
elif a == "-n" and i + 1 < len(args):
try:
n = int(args[i + 1])
except ValueError:
pass
unit = unit[: -len(".service")] if unit.endswith(".service") else unit

for i in range(min(n, 20)):
emit(unit, f"[test harness] {unit}: backlog line {i + 1}")
if follow:
for k in itertools.count(1):
time.sleep(2)
emit(unit, f"[test harness] {unit}: live tail tick {k}")
return 0


if __name__ == "__main__":
sys.exit(main())
103 changes: 103 additions & 0 deletions test/bin/systemctl
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
#!/usr/bin/env python3
"""Minimal `systemctl` stand-in for the no-systemd test container.

service-manager shells out to systemctl to read and toggle unit state. There is no
systemd here, so this shim answers the exact subset it calls (`show -p Id -p
UnitFileState -p ActiveState ...`, `is-active`, `is-enabled`, and the
start/stop/restart/enable/disable/reset-failed actions) from a small JSON state file,
so the Services page renders and its buttons actually flip state.

State is seeded from the installed manifests on first use. The services that genuinely
run in this harness are reported active; the rest start inactive and can be "started"
from the UI (which just flips the flag here).
"""
import json
import os
import sys

STATE = "/run/ark-os-test/units.json"
MANIFEST_DIR = "/usr/lib/ark-os/manifests"
# Units that are real processes in this harness (or the go2rtc sidecar).
RUNNING = {
"system-manager", "service-manager", "connection-manager",
"autopilot-manager", "ark-ui-backend", "go2rtc",
}


def load() -> dict:
if os.path.exists(STATE):
with open(STATE) as fh:
return json.load(fh)
units = {}
if os.path.isdir(MANIFEST_DIR):
for fn in os.listdir(MANIFEST_DIR):
if fn.endswith(".manifest.json"):
name = fn[: -len(".manifest.json")]
units[name] = {
"active": "active" if name in RUNNING else "inactive",
"enabled": "enabled",
}
save(units)
return units


def save(units: dict) -> None:
os.makedirs(os.path.dirname(STATE), exist_ok=True)
with open(STATE, "w") as fh:
json.dump(units, fh)


def norm(name: str) -> str:
return name[: -len(".service")] if name.endswith(".service") else name


def main() -> int:
args = sys.argv[1:]
if not args:
return 0
op = args[0]
units = load()
# Anything ending in .service (show) or matching a known unit is a target.
targets = [a for a in args[1:] if a.endswith(".service") or norm(a) in units]

if op == "show":
blocks = []
for tgt in targets:
name = norm(tgt)
st = units.get(name, {"active": "inactive", "enabled": "disabled"})
blocks.append(
f"Id={name}.service\n"
f"UnitFileState={st['enabled']}\n"
f"ActiveState={st['active']}\n"
)
sys.stdout.write("\n".join(blocks))
return 0

name = norm(targets[0]) if targets else None
if op == "is-active":
state = units.get(name, {}).get("active", "inactive")
print(state)
return 0 if state == "active" else 3
if op == "is-enabled":
state = units.get(name, {}).get("enabled", "disabled")
print(state)
return 0 if state == "enabled" else 1

if name is None or name not in units:
# Unknown unit: succeed quietly (service-manager already gated on manifests).
return 0
if op in ("start", "restart"):
units[name]["active"] = "active"
elif op == "stop":
units[name]["active"] = "inactive"
elif op == "enable":
units[name]["enabled"] = "enabled"
elif op == "disable":
units[name]["enabled"] = "disabled"
# reset-failed and anything else: no-op success.
save(units)
return 0


if __name__ == "__main__":
sys.exit(main())
21 changes: 21 additions & 0 deletions test/configs/mavlink-router.conf
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
[General]
# Test-harness copy of services/mavlink-router/main.conf, editable from the Services
# page (mounted into the container at /etc/ark-os/mavlink-router.conf). mavlink-router
# itself does not run in the x86 services-only harness, so edits here just exercise the
# config editor; the fake FC telemetry comes from test/mavlink_stub.py instead.
TcpServerPort = 0

[UartEndpoint FCUSB]
Device = /dev/serial/by-id/usb-ARK_ARK_FMU_v6X.x_0-if00
Baud = 2000000
FlowControl = false

[UdpEndpoint GCS]
Mode = Server
Address = 0.0.0.0
Port = 14550

[UdpEndpoint autopilot_manager]
Mode = Normal
Address = 127.0.0.1
Port = 14571
33 changes: 33 additions & 0 deletions test/docker-compose.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
name: ark-os-test

services:
# The userspace stack in one container (managers + gateway + nginx + fake FC), built
# from the repo. Browse the UI at http://localhost:8080.
arkos:
build:
context: ..
dockerfile: test/Dockerfile
image: ark-os-test:latest
init: true
depends_on:
- go2rtc
ports:
- "8080:80"
volumes:
# Manifests drive the Services page; configs are editable from it. Mounted (not
# baked) so you can tweak them without rebuilding.
- ./manifests:/usr/lib/ark-os/manifests:ro
- ./configs/mavlink-router.conf:/etc/ark-os/mavlink-router.conf
# Same file the go2rtc sidecar reads — editing it in the UI affects the real stream.
- ./go2rtc.test.yaml:/etc/ark-os/go2rtc.yaml

# Synthetic camera. The official image bundles ffmpeg, so the test pattern source in
# go2rtc.test.yaml works with no /dev/video* device.
go2rtc:
image: alexxit/go2rtc:latest
volumes:
- ./go2rtc.test.yaml:/config/go2rtc.yaml
ports:
# WebRTC media flows browser<->here directly; must be reachable from the host.
- "8555:8555/tcp"
- "8555:8555/udp"
25 changes: 25 additions & 0 deletions test/go2rtc.test.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# go2rtc config for the LOCAL TEST HARNESS (no camera).
#
# camera1 is a synthetic ffmpeg test pattern instead of rtsp-server's real RTSP feed,
# so the Video page streams without any /dev/video* device. Swap it for a file loop to
# see "real" footage, e.g.:
# camera1: "ffmpeg:/media/sample.mp4#video=h264#input=-stream_loop -1 -re"
streams:
camera1: "ffmpeg:virtual?video=testsrc&size=1280x720#video=h264"

# Bound to 0.0.0.0 (not 127.0.0.1 as on-device) because nginx runs in a separate
# container here and proxies /video/ to this one over the compose network.
api:
listen: ":1984"

# WebRTC media flows directly browser<->container over 8555 (published to the host by
# docker-compose). candidates must be the address the host browser can reach, i.e. the
# published port on localhost — auto-detection would advertise the container's internal
# IP, which the browser can't route to.
webrtc:
listen: ":8555"
candidates:
- "127.0.0.1:8555"

log:
level: info
6 changes: 6 additions & 0 deletions test/manifests/dds-agent.manifest.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"displayName": "DDS Agent",
"description": "uXRCE-DDS agent bridging PX4 uORB topics to ROS 2.",
"configFile": "",
"visible": true
}
6 changes: 6 additions & 0 deletions test/manifests/go2rtc.manifest.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"displayName": "Video Gateway (go2rtc)",
"description": "WebRTC gateway that streams the camera to the web UI's Video page.",
"configFile": "go2rtc.yaml",
"visible": true
}
Loading
Loading