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
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -28,3 +28,5 @@ __pycache__/
# Installer state
installer/docker-compose/.data/
installer/docker-compose/.secrets/
/bin/
/.run/
61 changes: 37 additions & 24 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,33 +113,46 @@ The Makefile is the canonical interface. Discover targets with `make`
or `make help`.

```bash
# Building
# Build / lint / test
make tidy # go mod tidy across both modules
make build # builds bin/{controlplane,telemetry-ingest,ota-worker,agent}

# Linting / testing
make build # bin/{controlplane,telemetry-ingest,ota-worker,collision-worker,agent}
make lint # go vet + (optional) staticcheck on both modules
make test # go test -race -count=1 ./...

# Lab stack (auto-detects docker vs podman)
make container-info # confirm the engine + compose command
make lab-up # validation cluster on 14xxx ports
make lab-status # probe ports
make lab-down # stop, keep state
make lab-reset # stop + wipe state

# CI/smoke cluster — alternate ports (2xxxx); coexists with lab
make ci-up # what the pre-push hook + GH Actions run
make ci-status
make ci-down # tears down + wipes state

# Sim (Gazebo + TurtleBot3 + bridge + agent in containers)
make sim-up
make sim-logs
make sim-down

# Protobuf regeneration
make proto # requires protoc + protoc-gen-go + protoc-gen-go-grpc
make proto # regen Go + Python protobuf bindings via containerized protoc

# Lab cluster (compose). Auto-detects docker vs podman.
make container-info # which engine + compose command
make lab-up / lab-down / lab-status / lab-reset

# Sim (gazebo + robot + lab cluster) — preferred starting point.
make sim-up # gazebo container + robot container + lab cluster
make sim-up-headless # no GUI; gz server only
make sim-gui # open the Gazebo browser GUI in default browser
make sim-down / sim-logs

# Host-side processes (Go binaries, PID files in .run/).
# These do NOT run in compose; they live on the macOS host so they
# can talk to the host's docker/podman CLI without bind-mount-socket
# rootless gymnastics.
make agent-up / agent-down / agent-status # the rover's agent
make workers-up / workers-down / workers-status # ota-worker + collision-worker
make controlplane-up / controlplane-down / controlplane-status # OTA HTTP API on :8081

# Drive helpers — direct gz-topic publish from inside the gazebo
# container; no ROS install required on the host.
make sim-drive-fwd LX=0.5 # default 0.5 m/s; override LX=
make sim-drive-back / sim-drive-left / sim-drive-right / sim-drive-stop

# Demos — one command per scenario.
make ota-circle # build + push + roll out drive-circle
make ota-figure-eight # build + push + roll out drive-figure-eight
make ota-status # GET /v1/ota/rollouts (jq if present)
make collide # publish a fake collision event;
# triggers the CollisionResponse Temporal workflow

# CI/smoke cluster — same services, alternate (2xxxx) ports so it
# coexists with `lab-up`. What the pre-push hook + GH Actions run.
make ci-up / ci-down / ci-status
```

End-to-end OTA walkthrough lives in `ONBOARDING.md` Section 9.
Expand Down
160 changes: 160 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,166 @@ build-cloud:
cd cloud && go build -o ../bin/controlplane ./cmd/controlplane
cd cloud && go build -o ../bin/telemetry-ingest ./cmd/telemetry-ingest
cd cloud && go build -o ../bin/ota-worker ./cmd/ota-worker
cd cloud && go build -o ../bin/collision-worker ./cmd/collision-worker

# =============================================================================
# Workflow workers — host-side Go binaries that connect to the lab
# Temporal frontend + MQTT broker. Without these running, the
# Temporal UI shows no Workers / no in-flight Workflows.
# =============================================================================

WORKER_TEMPORAL_ADDR ?= localhost:14733
WORKER_BROKER_URL ?= tcp://localhost:14883
WORKER_TSDB_DSN ?= postgres://temporal:temporal@localhost:14432/telemetry?sslmode=disable

# Native agent — runs as a Go binary on the host (macOS). Avoids
# bind-mounting the container-runtime socket into a sibling
# container (rootless podman idmap makes that path painful) and
# uses the host's docker/podman CLI directly when an OTA fires.
AGENT_ROBOT_ID ?= sim-robot-01
AGENT_BROKER_URL ?= tcp://localhost:14883
AGENT_BRIDGE_ADDR ?= localhost:50051
AGENT_BUFFER_PATH ?= .run/agent-buffer.db
AGENT_OTA_RUN_ARGS ?= --network=temporal-hack-lab_default,-e,ROS_DOMAIN_ID=42,-e,RMW_IMPLEMENTATION=rmw_cyclonedds_cpp

.PHONY: agent-up
agent-up: build-agent ## Start the agent natively on the host (preferred for local dev)
@mkdir -p .run
@ROBOT_ID=$(AGENT_ROBOT_ID) \
BROKER_URL=$(AGENT_BROKER_URL) \
BRIDGE_ADDR=$(AGENT_BRIDGE_ADDR) \
BUFFER_PATH=$(AGENT_BUFFER_PATH) \
OTA_RUN_ARGS="$(AGENT_OTA_RUN_ARGS)" \
nohup ./bin/agent > .run/agent.log 2>&1 & echo $$! > .run/agent.pid
@sleep 1
@echo "agent PID $$(cat .run/agent.pid 2>/dev/null) log .run/agent.log"

.PHONY: agent-down
agent-down: ## Stop the native agent
@[ -f .run/agent.pid ] && kill "$$(cat .run/agent.pid)" 2>/dev/null && rm -f .run/agent.pid && echo "stopped agent" || true

.PHONY: agent-status
agent-status: ## Show native agent status
@pid="$$(cat .run/agent.pid 2>/dev/null || echo '')"; \
if [ -n "$$pid" ] && kill -0 "$$pid" 2>/dev/null; then echo "agent: running (pid $$pid)"; \
else echo "agent: not running"; fi

.PHONY: workers-up
workers-up: build-cloud ## Start ota-worker + collision-worker in the background
@mkdir -p .run
@TEMPORAL_ADDR=$(WORKER_TEMPORAL_ADDR) BROKER_URL=$(WORKER_BROKER_URL) \
TSDB_DSN="$(WORKER_TSDB_DSN)" \
nohup ./bin/ota-worker > .run/ota-worker.log 2>&1 & echo $$! > .run/ota-worker.pid
@TEMPORAL_ADDR=$(WORKER_TEMPORAL_ADDR) BROKER_URL=$(WORKER_BROKER_URL) \
nohup ./bin/collision-worker > .run/collision-worker.log 2>&1 & echo $$! > .run/collision-worker.pid
@sleep 1
@echo "ota-worker PID $$(cat .run/ota-worker.pid 2>/dev/null) log .run/ota-worker.log"
@echo "collision-worker PID $$(cat .run/collision-worker.pid 2>/dev/null) log .run/collision-worker.log"

.PHONY: workers-down
workers-down: ## Stop the workflow workers
@for f in .run/ota-worker.pid .run/collision-worker.pid; do \
[ -f $$f ] && kill "$$(cat $$f)" 2>/dev/null && rm -f $$f && echo "stopped $$f" || true; \
done

.PHONY: workers-status
workers-status: ## Show status of running workflow workers
@for n in ota-worker collision-worker; do \
pid="$$(cat .run/$$n.pid 2>/dev/null || echo '')"; \
if [ -n "$$pid" ] && kill -0 "$$pid" 2>/dev/null; then \
echo "$$n: running (pid $$pid)"; \
else \
echo "$$n: not running"; \
fi; \
done

# Publish a fake collision event to trigger a CollisionResponse workflow.
# Uses the lab broker directly via the robot container's paho client.
.PHONY: collide
collide: ## Publish a fake collision event for sim-robot-01 (triggers Temporal workflow)
@$(CONTAINER_ENGINE) exec temporal-hack-lab-robot-1 python3 -c \
"import paho.mqtt.publish as p, time, json; p.single('events/sim-robot-01/collision', json.dumps({'robot_id':'sim-robot-01','at':time.time(),'count':1,'partner':'manual-trigger'}), hostname='mqtt', port=1883, qos=1)"
@echo "published events/sim-robot-01/collision; check Temporal UI for collision-* workflow"

# =============================================================================
# Control plane (HTTP API for OTA rollouts) — host-side binary, not in
# compose. Required to start a rollout via /v1/ota/rollouts. Same
# pattern as workers-up / workers-down.
# =============================================================================

CP_LISTEN_ADDR ?= :8081

.PHONY: controlplane-up
controlplane-up: build-cloud ## Start the control plane HTTP API in the background
@mkdir -p .run
@LISTEN_ADDR=$(CP_LISTEN_ADDR) \
TEMPORAL_ADDR=$(WORKER_TEMPORAL_ADDR) \
TSDB_DSN="$(WORKER_TSDB_DSN)" \
nohup ./bin/controlplane > .run/controlplane.log 2>&1 & echo $$! > .run/controlplane.pid
@sleep 1
@echo "controlplane PID $$(cat .run/controlplane.pid 2>/dev/null) log .run/controlplane.log"
@echo " POST http://localhost$(CP_LISTEN_ADDR)/v1/ota/rollouts to start an OTA"

.PHONY: controlplane-down
controlplane-down: ## Stop the control plane API
@[ -f .run/controlplane.pid ] && kill "$$(cat .run/controlplane.pid)" 2>/dev/null && rm -f .run/controlplane.pid && echo "stopped controlplane" || true

.PHONY: controlplane-status
controlplane-status: ## Show control plane status
@pid="$$(cat .run/controlplane.pid 2>/dev/null || echo '')"; \
if [ -n "$$pid" ] && kill -0 "$$pid" 2>/dev/null; then \
echo "controlplane: running (pid $$pid) at http://localhost$(CP_LISTEN_ADDR)"; \
else echo "controlplane: not running"; fi

# =============================================================================
# OTA demo helpers — build the controller image, push it to the lab
# registry, fire a rollout. One command per controller.
# =============================================================================

OTA_REGISTRY ?= localhost:14050
OTA_ROBOT_ID ?= sim-robot-01
OTA_CP_HOST ?= http://localhost:8081

# Internal: build + push a single controller. Args: $(1)=name (matches sim/controllers/<name>),
# $(2)=tag suffix.
define _ota_build_push
@echo "[ota] building sim/controllers/$(1) → $(OTA_REGISTRY)/robot-app:$(2)"
$(CONTAINER_ENGINE) build \
-t $(OTA_REGISTRY)/robot-app:$(2) \
-f sim/controllers/$(1)/Dockerfile \
sim/controllers/$(1)
$(CONTAINER_ENGINE) push --tls-verify=false $(OTA_REGISTRY)/robot-app:$(2)
endef

# Internal: POST a rollout for the given image tag.
define _ota_rollout
@echo "[ota] starting rollout for $(OTA_REGISTRY)/robot-app:$(1) on $(OTA_ROBOT_ID)"
@curl -sS -X POST $(OTA_CP_HOST)/v1/ota/rollouts \
-H "content-type: application/json" \
-d '{ \
"image_ref": "$(OTA_REGISTRY)/robot-app:$(1)", \
"smoke_command": "true", \
"smoke_timeout_sec": 10, \
"cohort_selector": {"robot_ids": ["$(OTA_ROBOT_ID)"]} \
}' && echo
endef

.PHONY: ota-circle
ota-circle: ## Build, push, and OTA-roll the drive-circle controller
$(call _ota_build_push,drive-circle,circle-v1)
$(call _ota_rollout,circle-v1)
@echo "watch the rover at http://localhost:14680 — should start driving in a circle"
@echo "rollout status: curl -s $(OTA_CP_HOST)/v1/ota/rollouts | jq"

.PHONY: ota-figure-eight
ota-figure-eight: ## Build, push, and OTA-roll the drive-figure-eight controller
$(call _ota_build_push,drive-figure-eight,figure-eight-v1)
$(call _ota_rollout,figure-eight-v1)
@echo "watch the rover at http://localhost:14680 — should start tracing a figure 8"

.PHONY: ota-status
ota-status: ## List recent OTA rollouts (requires controlplane-up)
@curl -sS $(OTA_CP_HOST)/v1/ota/rollouts | (command -v jq >/dev/null && jq || cat)

.PHONY: build-agent
build-agent:
Expand Down
92 changes: 66 additions & 26 deletions ONBOARDING.md
Original file line number Diff line number Diff line change
Expand Up @@ -295,31 +295,33 @@ same host.

## Section 7 — Run the platform end-to-end

In four terminals:
The host-side processes are managed by `make`. Four targets bring up
the whole stack; each writes its PID to `.run/` and tails to a
matching `.run/<name>.log`.

```bash
# Terminal 1 — lab stack already up (Section 6)
make lab-up

# Terminal 2 — telemetry ingester
TSDB_DSN="postgres://temporal:temporal@localhost:5432/telemetry?sslmode=disable" \
./bin/telemetry-ingest
# Terminal 1 — lab cluster + sim (one make target)
make sim-up # gazebo + robot + Postgres + Temporal + MQTT + registry

# Terminal 3 — control plane API
./bin/controlplane
# Terminal 2 — three host-side runners (no foreground; check .run/*.log)
make agent-up # native agent → talks to localhost:14883 / :50051
make workers-up # ota-worker + collision-worker
make controlplane-up # OTA HTTP API on :8081
```

# Terminal 4 — OTA worker (Temporal worker + MQTT bridge)
./bin/ota-worker
Verify:

# Then start an agent (a fifth terminal, or detached)
ROBOT_ID=lab-robot-01 ./bin/agent
```bash
make agent-status workers-status controlplane-status
curl -s http://localhost:8081/healthz # 200
curl -s http://localhost:8081/v1/robots | jq # heartbeats from sim-robot-01
make sim-gui # Gazebo GUI in browser
```

Verify operator reads:
Tear down (reverse order):

```bash
curl -s http://localhost:8081/healthz
curl -s http://localhost:8081/v1/robots | jq
make controlplane-down && make workers-down && make agent-down && make sim-down
```

---
Expand All @@ -343,20 +345,56 @@ The Gazebo GUI is exposed over noVNC, so it works in any browser

---

## Section 9 — Trigger a test OTA
## Section 9 — Demos: OTA + Collision

Both demos assume Section 7's four targets are up.

### OTA — swap a robot-app live (Temporal-orchestrated)

One command per scenario. Builds the controller image, pushes it to
the lab registry on `:14050`, and POSTs a rollout to the control
plane:

```bash
make ota-circle # rover starts driving in a circle
make ota-figure-eight # swap to a figure-8 controller
make ota-status # GET /v1/ota/rollouts (jq if installed)
```

Watch the workflow at `http://localhost:14080/namespaces/default/workflows`
— a `rollout-…` ID appears, completes in 1–2 seconds, and the
`robot-app` container under `podman ps` flips to the new image.

### Collision — Temporal drives the rover out of an obstacle

The moon world has a 0.9 m boulder at `x = 8`. A contact sensor on
the rover deck publishes Gazebo Contact messages → ros_gz_bridge →
ROS `/contacts` → `collision_publisher` (in the robot container) →
MQTT `events/{robot_id}/collision` → `collision-worker` →
`CollisionResponse` Temporal workflow → MQTT `cmd/{robot_id}/twist`
→ `twist_subscriber` → ROS `/cmd_vel` → gz `DiffDrive`.

```bash
make collide # publish a fake collision event
# OR drive the rover into the boulder for real:
make sim-drive-fwd LX=1.0 # call repeatedly until impact
```

`CollisionResponse` runs back-up → 90° turn-right → forward →
stop. Visible in the Gazebo GUI and at `http://localhost:14080`.

### (Original raw-API form, kept for reference)

With the lab stack and worker up:
If you want to see the underlying contract:

```bash
# Build and push the lab dummy robot image (Alpine; stays running for swap checks)
docker build -t localhost:5001/robot-app:v1 -f docker/dummy-robot/Dockerfile docker/dummy-robot
docker push localhost:5001/robot-app:v1
podman build -t localhost:14050/robot-app:v1 -f docker/dummy-robot/Dockerfile docker/dummy-robot
podman push --tls-verify=false localhost:14050/robot-app:v1

# Start a rollout
curl -X POST http://localhost:8081/v1/ota/rollouts \
-H "content-type: application/json" \
-d '{
"image_ref": "localhost:5001/robot-app:v1",
"image_ref": "localhost:14050/robot-app:v1",
"smoke_command": "true",
"cohort_selector": {"robot_ids": ["lab-robot-01"]}
}'
Expand Down Expand Up @@ -389,11 +427,13 @@ If all three are green, you're set up. Welcome.
| Symptom | Likely cause | Fix |
|----------------------------------------|-------------------------------------|------------------------------------------------------------|
| `make lab-up` errors `no such image` | Pull failed or rate-limited | `docker login` / wait, retry |
| Postgres exits code 3 at lab-up | Wrong image (stock instead of TSDB) | We pin `timescale/timescaledb-ha`; rebase |
| Postgres exits code 3 at lab-up | Wrong image (stock instead of TSDB) | Pinned to `timescale/timescaledb-ha`; rebase if drifted |
| EMQX unhealthy | Port 1883 already in use | `lsof -i :1883`; stop the other broker |
| Agent crashes at startup | Buffer dir not writable | `chmod` the path or override `BUFFER_PATH` |
| Bridge node import error | `rclpy` not on `PYTHONPATH` | `source /opt/ros/humble/setup.bash` first |
| OTA stuck at PHASE_PULLED | Robot can't reach registry | Check robot's network to the registry hostname/port |
| OTA "docker not on PATH" | Native agent didn't pick up an engine | `make agent-status`; ensure `docker` or `podman` is on host PATH |
| OTA "http: server gave HTTP response to HTTPS client" | Podman pull tries HTTPS first | Agent appends `--tls-verify=false` for podman; if you use docker, configure `insecure-registries` |
| Rollout stuck `pending` for ~5 min | Pre-fix bug: agent emitted PHASE_FAILED on rollback failure | Fixed; `git pull` and rebuild agent. Old rows clear at 5-min rollback timer |
| Agent ports 50051 unreachable | `robot` service not publishing the port | `make sim-down && make sim-up` to pick up the latest compose |
| `pre-commit` hangs in installer-smoke | Slow image pull on first run | Run once manually: `bash .git-hooks/installer-smoke.sh` |

---
Expand Down
Loading