Skip to content
Draft
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
7 changes: 7 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# Python bytecode
__pycache__/
*.py[cod]

# Node dependencies (installed at deploy time by install.sh)
node_modules/
package-lock.json
8 changes: 4 additions & 4 deletions QUICK_START.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,12 +31,12 @@ Replace `<version>` with the target version (e.g. `9.2.0-rc6`). Downgrades work
Each example script sends one goal and exits; the Behavior Tree itself loops internally.

```bash
/usr/bin/3-waypoint-pick-and-place.py
/usr/bin/ml-segment-image.py
/usr/bin/move-all-boxes.py
/usr/bin/3-waypoint-pick-and-place.mjs
/usr/bin/ml-segment-image.mjs
/usr/bin/move-all-boxes.mjs
```

To wrap your own objective into a forever-loop driver, copy `example_scripts/cd_objective_lib.py` and call `run_objectives_forever([...])` with the Objective names you want to chain.
To wrap your own objective into a forever-loop driver, copy `example_scripts/cd_objective_lib.mjs` and call `runObjectivesForever([...])` with the Objective names you want to chain.

## Troubleshooting

Expand Down
13 changes: 7 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,11 @@ The full setup walkthrough lives at [Set Up CI/CD](https://docs.picknik.ai/how_t
- `bin/install-moveit-pro` — root-owned installer wrapper. Validates the version string against a strict regex, downloads the `.deb` to a root-owned cache, installs it, and deletes the file.
- `bin/moveit-pro@.service` — systemd template unit. Runs `moveit_pro run --no-browser` as `%i`. Restarts on failure. Reads optional environment from `/etc/default/moveit-pro`.
- `bin/notify-crash.py` — posts to Slack and opens/updates a GitHub issue via `ExecStopPost` when the service exits non-zero. Reads `SLACK_WEBHOOK_URL` and `MOVEIT_CD_GITHUB_TOKEN` from the environment; each notification is skipped if its variable is unset.
- `bin/notify_lib.py` — shared notification helpers (`slack_post`, `github_issue`) used by both `notify-crash.py` and `cd_objective_lib.py`. Installed to `/usr/lib/moveit-pro-scripts/`. `github_issue` deduplicates by exact title within a label: a repeated failure bumps an occurrence counter and appends a row instead of opening a new issue.
- `bin/notify_lib.py` — shared notification helpers (`slack_post`, `github_issue`) used by `notify-crash.py` (Python import) and by the CD objective runner (`cd_objective_lib.mjs`, via the module's `--title`/`--reason` CLI shim). Installed to `/usr/lib/moveit-pro-scripts/`. `github_issue` deduplicates by exact title within a label: a repeated failure bumps an occurrence counter and appends a row instead of opening a new issue.
- `bin/ci-runner.sudoers.template` — sudoers drop-in. `install.sh` substitutes `__CI_USER__` with the local account and installs at `/etc/sudoers.d/<user>-ci`. Grants NOPASSWD on the installer and the user's own systemd unit only.
- `example_scripts/cd_objective_lib.py` — helper library for sending an Objective goal via rosbridge, used by the example scripts. On objective timeout or rosbridge failure it posts to Slack, opens/updates a GitHub issue, and stops the systemd unit (via `notify_lib.py`).
- `example_scripts/3-waypoint-pick-and-place.py`, `example_scripts/ml-segment-image.py`, `example_scripts/move-all-boxes.py` — example smoke-test scripts that drive an Objective on `localhost:3201` rosbridge.
- `example_scripts/cd_objective_lib.mjs` — Node helper library for sending an Objective goal over the MoveIt Pro web bridge (`foxglove_bridge`, port `3201`) using [`foxglove-ros-adapter`](https://www.npmjs.com/package/foxglove-ros-adapter). No `--enable-rosbridge` sidecar needed. On objective timeout or bridge failure it posts to Slack, opens/updates a GitHub issue, and stops the systemd unit (via `notify_lib.py`).
- `example_scripts/package.json` / `ws-polyfill.mjs` — Node dependency manifest (installed alongside the runner and `npm install`ed by `install.sh`) and the `WebSocket` global shim for Node 18–21.
- `example_scripts/3-waypoint-pick-and-place.mjs`, `example_scripts/ml-segment-image.mjs`, `example_scripts/move-all-boxes.mjs` — example smoke-test scripts that drive an Objective over the web bridge on `localhost:3201`.

## Install

Expand All @@ -28,7 +29,7 @@ sudo ./install.sh
This installs:

- The objective scripts to `/usr/bin/`.
- `cd_objective_lib.py` and `notify_lib.py` to `/usr/lib/moveit-pro-scripts/`.
- `cd_objective_lib.mjs` (with its Node dependencies via `npm install`) and `notify_lib.py` to `/usr/lib/moveit-pro-scripts/`.
- `notify-crash.py` to `/usr/bin/`.
- `install-moveit-pro` to `/usr/local/sbin/` (root-owned, `0755`).
- `/var/cache/moveit-pro/` as a root-owned download cache.
Expand Down Expand Up @@ -64,7 +65,7 @@ WORKSPACE_PIN_TO_RELEASE=false

### Optional: failure notifications (Slack + GitHub issues)

Both notifiers read their config from `/etc/default/moveit-pro` (root-owned). The systemd unit loads this file via `EnvironmentFile=`, so `notify-crash.py` and `cd_objective_lib.py` pick it up for crash and CD-failure events. Each notifier is independent: set only the variables you want.
Both notifiers read their config from `/etc/default/moveit-pro` (root-owned). The systemd unit loads this file via `EnvironmentFile=`, so `notify-crash.py` and the CD objective runner pick it up for crash and CD-failure events. Each notifier is independent: set only the variables you want.

```bash
sudo install -m 0640 -o root -g root /dev/stdin /etc/default/moveit-pro <<'EOF'
Expand Down Expand Up @@ -107,7 +108,7 @@ The CI runner SSHes into each target machine over a mesh VPN (Tailscale, WireGua

1. `sudo -n /usr/local/sbin/install-moveit-pro <version>` — downloads and installs the `.deb`.
2. `sudo -n /bin/systemctl restart moveit-pro@<user>.service` — restarts the service.
3. `/usr/bin/<objective>.py` — optional smoke test of an Objective via rosbridge.
3. `/usr/bin/<objective>.mjs` — optional smoke test of an Objective over the web bridge.

The sudoers drop-in grants NOPASSWD on **only** steps 1 and 2. The installer validates the version string with a strict regex and downloads to a root-owned path, so a compromised CI account cannot escalate by planting a malicious `.deb`.

Expand Down
40 changes: 39 additions & 1 deletion bin/notify_lib.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@
Two call sites fire on a QA deployment failure:

* notify-crash.py -- systemd ExecStopPost on non-zero service exit.
* cd_objective_lib.py -- objective-runner timeout / rosbridge failure.
* cd_objective_lib.mjs -- objective-runner timeout / web-bridge failure
(invokes this module's --title/--reason CLI shim).

Both post to Slack (SLACK_WEBHOOK_URL) and, when a token is configured, open
or update a deduplicated GitHub issue on the MoveIt Pro repo. Every function
Expand Down Expand Up @@ -324,3 +325,40 @@ def _row(n):
},
)
print(f"Updated GitHub issue #{number} (occurrence #{occurrence}): {title}")


def _main(argv=None):
"""CLI entry point so non-Python callers (e.g. the Node CD runner) can send
the same Slack + deduplicated-GitHub-issue failure notification this module
exposes to Python importers.

Best-effort: a failure in one channel never blocks the other, and the command
always exits 0 — notifications are auxiliary to stopping the service, which
the caller handles separately.
"""
import argparse

parser = argparse.ArgumentParser(
description="Send a CD failure notification (Slack + deduplicated GitHub issue)."
)
parser.add_argument("--title", required=True, help="GitHub issue title.")
parser.add_argument(
"--reason", required=True, help="Failure reason (Slack message + issue body)."
)
parser.add_argument(
"--dry-run", action="store_true", help="Log the payloads instead of posting."
)
args = parser.parse_args(argv)

try:
slack_post(build_payload(args.reason), dry_run=args.dry_run)
except Exception as exc: # noqa: BLE001 - notifications are best-effort
print(f"slack_post failed: {exc}", file=sys.stderr)
try:
github_issue(args.title, args.reason, dry_run=args.dry_run)
except Exception as exc: # noqa: BLE001 - notifications are best-effort
print(f"github_issue failed: {exc}", file=sys.stderr)


if __name__ == "__main__":
_main()
4 changes: 4 additions & 0 deletions example_scripts/3-waypoint-pick-and-place.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
#!/usr/bin/env node
import { runObjective } from "/usr/lib/moveit-pro-scripts/cd_objective_lib.mjs";

await runObjective("3 Waypoints Pick and Place");
10 changes: 0 additions & 10 deletions example_scripts/3-waypoint-pick-and-place.py

This file was deleted.

241 changes: 241 additions & 0 deletions example_scripts/cd_objective_lib.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,241 @@
// Shared CD objective runner (Foxglove web bridge).
//
// Connects to the always-on MoveIt Pro web bridge (foxglove_bridge, port 3201),
// waits up to 1 hour for the /do_objective action server, sends the objective
// goal, then exits. On timeout: stops the moveit-pro service, posts a Slack
// failure notification, and opens or updates a deduplicated GitHub issue (both
// via notify_lib.py). Unlike the previous roslibpy version this needs no
// --enable-rosbridge sidecar — it speaks the Foxglove protocol to port 3201.
//
// Intended to be launched detached from CI over SSH; the calling shell can exit
// immediately and the script keeps running on the host.

import "./ws-polyfill.mjs"; // must precede the adapter import (installs global WebSocket on Node < 22)
import { Ros, ActionClient } from "foxglove-ros-adapter";

import { execFileSync } from "node:child_process";
import os from "node:os";

const BRIDGE_URL = process.env.MOVEIT_WEB_BRIDGE_URL ?? "ws://localhost:3201";
const ACTION_NAME = "/do_objective";
const ACTION_TYPE = "moveit_studio_sdk_msgs/action/DoObjectiveSequence";

const TOTAL_TIMEOUT_MS = 3600_000;
const POLL_INTERVAL_MS = 10_000;
const CONNECT_TIMEOUT_MS = 10_000;
const ACTION_WAIT_MS = 10_000;
const SEND_GOAL_DRAIN_MS = 5_000;

// notify_lib.py is installed alongside this module; override for local testing.
const NOTIFY_LIB =
process.env.MOVEIT_NOTIFY_LIB ?? "/usr/lib/moveit-pro-scripts/notify_lib.py";

// Objective context for the GitHub issue title, set once the runner knows which
// objective(s) it is driving. null until then (e.g. the bridge never came up).
let currentObjective = null;

const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

function failureTitle() {
const suffix = currentObjective ? ` — ${currentObjective}` : "";
return `QA deployment failure: ${os.hostname()}${suffix}`;
}

// Notifications are auxiliary: a missing/broken notify_lib must not stop the
// runner from stopping the service. Degrade loudly.
function notify(title, reason) {
try {
execFileSync("python3", [NOTIFY_LIB, "--title", title, "--reason", reason], {
stdio: "inherit",
});
} catch (err) {
console.error(`notify_lib failed, notifications skipped: ${err.message}`);
}
}

function stopService() {
const user = process.env.USER || process.env.LOGNAME || "";
if (!user) {
console.error("Cannot determine user for systemctl stop");
return;
}
const unit = `moveit-pro@${user}.service`;
console.log(`Stopping ${unit}`);
try {
execFileSync("sudo", ["-n", "/bin/systemctl", "stop", unit], {
stdio: "inherit",
});
} catch (err) {
console.error(`systemctl stop failed: ${err.message}`);
}
}

function fail(reason) {
console.error(reason);
notify(failureTitle(), reason);
stopService();
process.exit(1);
}

// Wait for the web bridge to accept a WebSocket connection. The adapter does not
// auto-reconnect, so retry a fresh Ros per attempt until the deadline.
async function connectWithRetry(deadline) {
let attempt = 0;
while (Date.now() < deadline) {
attempt += 1;
const ros = new Ros({ url: BRIDGE_URL });
const connected = await new Promise((resolve) => {
let settled = false;
const finish = (ok) => {
if (!settled) {
settled = true;
resolve(ok);
}
};
const timer = setTimeout(() => finish(false), CONNECT_TIMEOUT_MS);
ros.on("connection", () => {
clearTimeout(timer);
finish(true);
});
ros.on("error", () => {
clearTimeout(timer);
finish(false);
});
ros.on("close", () => {
clearTimeout(timer);
finish(false);
});
});
if (connected) {
console.log(`Web bridge connected at ${BRIDGE_URL} (attempt ${attempt})`);
return ros;
}
try {
ros.close();
} catch {
// ignore
}
console.log(`Waiting for web bridge at ${BRIDGE_URL}... (attempt ${attempt})`);
await sleep(POLL_INTERVAL_MS);
}
fail("Timeout: web bridge did not connect within 1h");
}

// Poll until the /do_objective action server is advertised. waitForAction checks
// the hidden action services the bridge exposes; it rejects if not ready within
// its own timeout, so wrap it in the overall deadline loop.
async function waitForActionServer(ros, deadline) {
let attempt = 0;
while (Date.now() < deadline) {
attempt += 1;
try {
await ros.waitForAction(ACTION_NAME, ACTION_WAIT_MS);
console.log(`${ACTION_NAME} action server is up (attempt ${attempt})`);
return;
} catch {
console.log(`Waiting for ${ACTION_NAME} action server... (attempt ${attempt})`);
await sleep(POLL_INTERVAL_MS);
}
}
fail(`Timeout: ${ACTION_NAME} action server not ready within 1h`);
}

// Send one objective goal and return immediately after a short drain, without
// waiting for the action to terminate. Used for fire-and-forget smoke tests
// whose Behavior Tree self-loops or runs indefinitely.
export async function runObjective(objectiveName) {
currentObjective = objectiveName;
const deadline = Date.now() + TOTAL_TIMEOUT_MS;
const ros = await connectWithRetry(deadline);
try {
await waitForActionServer(ros, deadline);
const client = new ActionClient({
ros,
name: ACTION_NAME,
actionType: ACTION_TYPE,
});
console.log(`Sending objective: ${objectiveName}`);
client.sendGoal(
{ objective_name: objectiveName },
() => {},
() => {},
(err) => console.error(`Action error for '${objectiveName}': ${err}`),
);
await sleep(SEND_GOAL_DRAIN_MS);
console.log(`Objective '${objectiveName}' sent successfully`);
} finally {
try {
ros.close();
} catch {
// ignore
}
}
}

// Send one objective goal and block until the action terminates. Any terminal
// status (success, abort, cancel) counts as completion — the result payload is
// opaque to this script, and real crashes are caught by the systemd
// notify-crash hook. Only rosbridge errors or a per-objective timeout fail().
async function sendAndWait(client, objectiveName, perObjectiveTimeoutMs) {
currentObjective = objectiveName;
console.log(`Sending objective: ${objectiveName}`);
const goalId = client.sendGoal(
{ objective_name: objectiveName },
() => {},
() => {},
(err) => console.error(`Action error for '${objectiveName}': ${err}`),
);
try {
await client.waitGoal(goalId, perObjectiveTimeoutMs);
} catch {
fail(
`Timeout: objective '${objectiveName}' did not terminate within ` +
`${Math.round(perObjectiveTimeoutMs / 1000)}s`,
);
}
console.log(`Objective '${objectiveName}' terminated`);
}

// Send each objective in order, wait for it to terminate, pause, then repeat —
// forever. Used by customer-config CD machines whose BT XML does not self-loop.
// Stuck objectives or bridge errors call fail() (Slack + GitHub issue + stop the
// unit); healthy iterations log and continue.
export async function runObjectivesForever(
objectives,
{ iterationPauseMs = 5_000, perObjectiveTimeoutMs = 3600_000 } = {},
) {
if (!objectives || objectives.length === 0) {
fail("runObjectivesForever called with empty objectives list");
}

// Seed the issue-title context with the whole list; sendAndWait narrows it to
// the specific objective once sending starts. This initial value is only what
// a pre-send failure (bridge / action server never came up) reports.
currentObjective = objectives.join(", ");

const deadline = Date.now() + TOTAL_TIMEOUT_MS;
const ros = await connectWithRetry(deadline);
try {
await waitForActionServer(ros, deadline);
const client = new ActionClient({
ros,
name: ACTION_NAME,
actionType: ACTION_TYPE,
});
let iteration = 0;
for (;;) {
iteration += 1;
console.log(`--- Iteration ${iteration} ---`);
for (const objectiveName of objectives) {
await sendAndWait(client, objectiveName, perObjectiveTimeoutMs);
}
await sleep(iterationPauseMs);
}
} finally {
try {
ros.close();
} catch {
// ignore
}
}
}
Loading
Loading