Skip to main content

mecha10_cli/services/
rl_bundle.rs

1//! RL project bundle packaging + upload (LAB-2768)
2//!
3//! `mecha10 build rl` packages a project's `configs/rl/` (job/task definitions - `jobs/*.yml`,
4//! `tasks/*/task.json`) and `configs/simulation/{models,environments}` (the assets those jobs
5//! reference) into a single tar.gz and uploads it, scoped by the project's registered
6//! `robot_id`, via the endpoint LAB-2767 adds to mecha10-api. `mecha10 run rl` (on the trainer
7//! host) downloads and unpacks this same bundle before training starts - see "RL: Vision and
8//! Phased Roadmap" doc.
9//!
10//! This bundle is deliberately separate from `mecha10 config push`'s `configs/` archive
11//! (`mecha10_robot_config::build_configs_archive`): that one syncs the *whole* `configs/`
12//! directory to the robot-config KV service for the robot/launcher to consume at runtime, while
13//! this one is a purpose-built blob (Minio-backed via LAB-2767, not the KV store) for exactly
14//! what a remote/Kestra-dispatched training run needs - replacing the old `simulation-assets/*`
15//! KV convention `mecha10 sim push`/`pull` used for this purpose (see LAB-2767's context: that
16//! mechanism is real, working code, but nothing downstream ever reads what it pushes today).
17//!
18//! Only `models`/`environments` ride along from `configs/simulation/` - not `images` (the flat
19//! billboard-image pool `environment.json`/`task.json` objects reference by path elsewhere,
20//! already synced to the robot separately via `mecha10 config push`) - matching this ticket's
21//! (LAB-2768) explicit requirement.
22
23use anyhow::{Context, Result};
24use flate2::read::GzDecoder;
25use flate2::write::GzEncoder;
26use flate2::Compression;
27use std::path::Path;
28use tar::{Archive, Builder};
29
30/// `configs/simulation/` subdirectories folded into the RL bundle - deliberately excludes
31/// `images` (the flat billboard-image pool synced separately via `mecha10 config push`).
32const SIMULATION_ASSET_DIRS: [&str; 2] = ["models", "environments"];
33
34/// Build the RL project bundle archive (tar.gz) for `project_root`, or `Ok(None)` if the project
35/// has neither a `configs/rl/` directory nor any `configs/simulation/{models,environments}`
36/// content to package - callers should skip the upload step entirely in that case rather than
37/// upload an empty archive.
38///
39/// Archive layout mirrors the source tree exactly (`configs/rl/...`,
40/// `configs/simulation/models/...`, `configs/simulation/environments/...`) so unpacking it on the
41/// trainer host (LAB-2767's download side, `mecha10 run rl`) drops files back into the same
42/// relative paths a local project uses - the same "archive root mirrors project layout"
43/// convention `mecha10_robot_config::build_configs_archive` and
44/// `scripts/release/build/package-templates.sh` both already use.
45pub fn build_rl_bundle_archive(project_root: &Path) -> Result<Option<Vec<u8>>> {
46    let rl_dir = project_root.join("configs").join("rl");
47    let simulation_dir = project_root.join("configs").join("simulation");
48
49    let asset_dirs: Vec<_> = SIMULATION_ASSET_DIRS
50        .iter()
51        .map(|name| (*name, simulation_dir.join(name)))
52        .filter(|(_, dir)| dir.exists())
53        .collect();
54
55    if !rl_dir.exists() && asset_dirs.is_empty() {
56        return Ok(None);
57    }
58
59    let mut bytes = Vec::new();
60    let encoder = GzEncoder::new(&mut bytes, Compression::default());
61    let mut builder = Builder::new(encoder);
62
63    if rl_dir.exists() {
64        builder
65            .append_dir_all("configs/rl", &rl_dir)
66            .context("Failed to archive configs/rl")?;
67    }
68
69    for (name, dir) in asset_dirs {
70        builder
71            .append_dir_all(format!("configs/simulation/{name}"), &dir)
72            .with_context(|| format!("Failed to archive configs/simulation/{name}"))?;
73    }
74
75    let encoder = builder.into_inner().context("Failed to finish RL bundle archive")?;
76    encoder.finish().context("Failed to finish RL bundle archive")?;
77
78    Ok(Some(bytes))
79}
80
81/// Tarball filename for an RL project bundle - matches the `<name>-v<version>-<suffix>.tar.gz`
82/// convention `scripts/release/build/package-templates.sh` (`mecha10-v{VERSION}-templates.tar.gz`)
83/// and `handlers::upload::handle_upload` (`{name}-v{version}-{platform}-{arch}.tar.gz`) already
84/// use for versioned release archives.
85pub fn rl_bundle_tarball_name(project_name: &str, version: &str) -> String {
86    format!("{project_name}-v{version}-rl-bundle.tar.gz")
87}
88
89/// URL for LAB-2767's robot-scoped RL project bundle upload endpoint. Isolated in its own
90/// function - matching `services::publish_status::build_versions_url`'s pattern - so if LAB-2767
91/// lands with a different path than assumed here, only this one function (and the tests pinned
92/// to it) need updating.
93///
94/// Assumed shape mirrors the existing robot-scoped, Minio-backed archive endpoint
95/// (`RobotConfigClient::upload_configs_archive`'s `PUT /api/robots/{robot_id}/configs/archive`):
96/// `PUT {control_plane_url}/api/robots/{robot_id}/rl-bundle`.
97fn build_rl_bundle_url(control_plane_url: &str, robot_id: &str) -> String {
98    format!(
99        "{}/api/robots/{}/rl-bundle",
100        control_plane_url.trim_end_matches('/'),
101        robot_id
102    )
103}
104
105/// Upload a pre-built RL project bundle archive for `robot_id`, authenticated the same way every
106/// other mecha10 CLI command talks to mecha10-api (`Authorization: Bearer <api_key>` +
107/// `X-User-ID`, see `handlers::upload::upload_to_registry`/`services::publish_status`).
108pub async fn upload_rl_bundle(
109    control_plane_url: &str,
110    robot_id: &str,
111    archive_bytes: Vec<u8>,
112    tarball_name: &str,
113    api_key: &str,
114    user_id: &str,
115) -> Result<()> {
116    let url = build_rl_bundle_url(control_plane_url, robot_id);
117
118    let part = reqwest::multipart::Part::bytes(archive_bytes)
119        .file_name(tarball_name.to_string())
120        .mime_str("application/gzip")
121        .context("Invalid mime type")?;
122    let form = reqwest::multipart::Form::new().part("file", part);
123
124    let client = reqwest::Client::new();
125    let response = client
126        .put(&url)
127        .header("Authorization", format!("Bearer {}", api_key))
128        .header("X-User-ID", user_id)
129        .multipart(form)
130        .send()
131        .await
132        .context("Failed to reach mecha10-api")?;
133
134    let status = response.status();
135    if !status.is_success() {
136        let body = response.text().await.unwrap_or_default();
137        anyhow::bail!("RL bundle upload failed with status {}: {}", status, body);
138    }
139
140    Ok(())
141}
142
143/// Download the RL project bundle previously uploaded for `robot_id` via [`upload_rl_bundle`] -
144/// LAB-2769's download side of LAB-2767's endpoint, called by `mecha10 run rl
145/// --download-bundle-only` on the trainer host. Authenticated identically to `upload_rl_bundle`
146/// (`Authorization: Bearer <api_key>` + `X-User-ID`); returns the raw tar.gz bytes for
147/// [`unpack_rl_bundle_archive`] to unpack.
148pub async fn download_rl_bundle(
149    control_plane_url: &str,
150    robot_id: &str,
151    api_key: &str,
152    user_id: &str,
153) -> Result<Vec<u8>> {
154    let url = build_rl_bundle_url(control_plane_url, robot_id);
155
156    let client = reqwest::Client::new();
157    let response = client
158        .get(&url)
159        .header("Authorization", format!("Bearer {}", api_key))
160        .header("X-User-ID", user_id)
161        .send()
162        .await
163        .context("Failed to reach mecha10-api")?;
164
165    let status = response.status();
166    if !status.is_success() {
167        let body = response.text().await.unwrap_or_default();
168        anyhow::bail!("RL bundle download failed with status {}: {}", status, body);
169    }
170
171    let bytes = response
172        .bytes()
173        .await
174        .context("Failed to read RL bundle download response body")?;
175
176    Ok(bytes.to_vec())
177}
178
179/// Unpack an RL project bundle archive (as built by [`build_rl_bundle_archive`] and returned by
180/// [`download_rl_bundle`]) into `dest_root` - entries land at `dest_root/configs/rl/...` and/or
181/// `dest_root/configs/simulation/{models,environments}/...`, mirroring the project-relative
182/// layout the archive was built from (same "archive root mirrors project layout" convention as
183/// `mecha10_robot_config::extract_configs_archive`). Overwrites files already present at the
184/// destination; does not delete anything at the destination that's absent from the archive.
185pub fn unpack_rl_bundle_archive(archive_bytes: &[u8], dest_root: &Path) -> Result<()> {
186    std::fs::create_dir_all(dest_root)
187        .with_context(|| format!("Failed to create destination directory {}", dest_root.display()))?;
188
189    let decoder = GzDecoder::new(archive_bytes);
190    let mut archive = Archive::new(decoder);
191    archive
192        .unpack(dest_root)
193        .context("Failed to unpack RL project bundle")?;
194
195    Ok(())
196}
197
198#[cfg(test)]
199mod tests {
200    use super::*;
201    use flate2::read::GzDecoder;
202    use std::collections::BTreeSet;
203    use tar::Archive;
204    use tempfile::tempdir;
205
206    fn archive_entries(bytes: &[u8]) -> BTreeSet<String> {
207        let decoder = GzDecoder::new(bytes);
208        let mut archive = Archive::new(decoder);
209        archive
210            .entries()
211            .unwrap()
212            .map(|e| e.unwrap().path().unwrap().to_string_lossy().to_string())
213            .collect()
214    }
215
216    #[test]
217    fn build_rl_bundle_archive_returns_none_when_nothing_to_package() {
218        let dir = tempdir().unwrap();
219        assert!(build_rl_bundle_archive(dir.path()).unwrap().is_none());
220    }
221
222    #[test]
223    fn build_rl_bundle_archive_includes_configs_rl_recursively() {
224        let dir = tempdir().unwrap();
225        std::fs::create_dir_all(dir.path().join("configs/rl/jobs")).unwrap();
226        std::fs::write(dir.path().join("configs/rl/jobs/train.yml"), "flow_type: train").unwrap();
227        std::fs::create_dir_all(dir.path().join("configs/rl/tasks/task1")).unwrap();
228        std::fs::write(dir.path().join("configs/rl/tasks/task1/task.json"), "{}").unwrap();
229
230        let bytes = build_rl_bundle_archive(dir.path()).unwrap().unwrap();
231        let entries = archive_entries(&bytes);
232
233        assert!(entries.contains("configs/rl/jobs/train.yml"));
234        assert!(entries.contains("configs/rl/tasks/task1/task.json"));
235    }
236
237    #[test]
238    fn build_rl_bundle_archive_includes_models_and_environments_only() {
239        let dir = tempdir().unwrap();
240        std::fs::create_dir_all(dir.path().join("configs/simulation/models/rover")).unwrap();
241        std::fs::write(dir.path().join("configs/simulation/models/rover/model.json"), "{}").unwrap();
242        std::fs::create_dir_all(dir.path().join("configs/simulation/environments/room")).unwrap();
243        std::fs::write(
244            dir.path().join("configs/simulation/environments/room/environment.json"),
245            "{}",
246        )
247        .unwrap();
248        std::fs::create_dir_all(dir.path().join("configs/simulation/images")).unwrap();
249        std::fs::write(dir.path().join("configs/simulation/images/aiko.jpg"), "not-a-real-jpg").unwrap();
250
251        let bytes = build_rl_bundle_archive(dir.path()).unwrap().unwrap();
252        let entries = archive_entries(&bytes);
253
254        assert!(entries.contains("configs/simulation/models/rover/model.json"));
255        assert!(entries.contains("configs/simulation/environments/room/environment.json"));
256        assert!(
257            !entries.iter().any(|e| e.starts_with("configs/simulation/images")),
258            "images/ must be excluded from the RL bundle: {entries:?}"
259        );
260    }
261
262    #[test]
263    fn build_rl_bundle_archive_packages_rl_configs_even_without_simulation_assets() {
264        let dir = tempdir().unwrap();
265        std::fs::create_dir_all(dir.path().join("configs/rl/jobs")).unwrap();
266        std::fs::write(dir.path().join("configs/rl/jobs/train.yml"), "flow_type: train").unwrap();
267
268        let bytes = build_rl_bundle_archive(dir.path()).unwrap().unwrap();
269        let entries = archive_entries(&bytes);
270        assert!(entries.contains("configs/rl/jobs/train.yml"));
271        assert!(
272            !entries.iter().any(|e| e.starts_with("configs/simulation")),
273            "no simulation assets exist in this project, none should be archived: {entries:?}"
274        );
275    }
276
277    #[test]
278    fn build_rl_bundle_archive_packages_simulation_assets_even_without_configs_rl() {
279        let dir = tempdir().unwrap();
280        std::fs::create_dir_all(dir.path().join("configs/simulation/models")).unwrap();
281        std::fs::write(dir.path().join("configs/simulation/models/m.json"), "{}").unwrap();
282
283        let bytes = build_rl_bundle_archive(dir.path()).unwrap().unwrap();
284        let entries = archive_entries(&bytes);
285        assert!(entries.contains("configs/simulation/models/m.json"));
286        assert!(
287            !entries.iter().any(|e| e.starts_with("configs/rl")),
288            "no configs/rl exists in this project, none should be archived: {entries:?}"
289        );
290    }
291
292    #[test]
293    fn rl_bundle_tarball_name_matches_versioned_archive_convention() {
294        assert_eq!(
295            rl_bundle_tarball_name("my-robot", "1.2.3"),
296            "my-robot-v1.2.3-rl-bundle.tar.gz"
297        );
298    }
299
300    #[test]
301    fn build_rl_bundle_url_scopes_by_robot_id_and_strips_trailing_slash() {
302        assert_eq!(
303            build_rl_bundle_url("https://api.example.com/", "robot-1"),
304            "https://api.example.com/api/robots/robot-1/rl-bundle"
305        );
306    }
307
308    #[tokio::test]
309    async fn upload_rl_bundle_errors_when_server_unreachable() {
310        // Point at a URL nothing is listening on - upload_rl_bundle must surface a real error,
311        // not panic or silently succeed.
312        let result = upload_rl_bundle(
313            "http://127.0.0.1:9",
314            "robot-1",
315            b"fake archive".to_vec(),
316            "robot-1-v1.0.0-rl-bundle.tar.gz",
317            "test-api-key",
318            "user-1",
319        )
320        .await;
321        assert!(result.is_err());
322    }
323
324    #[tokio::test]
325    async fn download_rl_bundle_errors_when_server_unreachable() {
326        // Mirrors upload_rl_bundle_errors_when_server_unreachable - download_rl_bundle must
327        // surface a real error, not panic or silently return an empty archive.
328        let result = download_rl_bundle("http://127.0.0.1:9", "robot-1", "test-api-key", "user-1").await;
329        assert!(result.is_err());
330    }
331
332    #[test]
333    fn unpack_rl_bundle_archive_round_trips_build_rl_bundle_archive() {
334        let src = tempdir().unwrap();
335        std::fs::create_dir_all(src.path().join("configs/rl/jobs")).unwrap();
336        std::fs::write(src.path().join("configs/rl/jobs/train.yml"), "flow_type: train").unwrap();
337        std::fs::create_dir_all(src.path().join("configs/simulation/models/rover")).unwrap();
338        std::fs::write(src.path().join("configs/simulation/models/rover/model.json"), "{}").unwrap();
339
340        let bytes = build_rl_bundle_archive(src.path()).unwrap().unwrap();
341
342        let dest = tempdir().unwrap();
343        unpack_rl_bundle_archive(&bytes, dest.path()).unwrap();
344
345        assert_eq!(
346            std::fs::read_to_string(dest.path().join("configs/rl/jobs/train.yml")).unwrap(),
347            "flow_type: train"
348        );
349        assert_eq!(
350            std::fs::read_to_string(dest.path().join("configs/simulation/models/rover/model.json")).unwrap(),
351            "{}"
352        );
353    }
354
355    #[test]
356    fn unpack_rl_bundle_archive_creates_missing_destination_directory() {
357        let src = tempdir().unwrap();
358        std::fs::create_dir_all(src.path().join("configs/rl/jobs")).unwrap();
359        std::fs::write(src.path().join("configs/rl/jobs/train.yml"), "flow_type: train").unwrap();
360        let bytes = build_rl_bundle_archive(src.path()).unwrap().unwrap();
361
362        let dest_parent = tempdir().unwrap();
363        let dest = dest_parent.path().join("does-not-exist-yet");
364        assert!(!dest.exists());
365
366        unpack_rl_bundle_archive(&bytes, &dest).unwrap();
367
368        assert!(dest.join("configs/rl/jobs/train.yml").exists());
369    }
370
371    #[test]
372    fn unpack_rl_bundle_archive_errors_on_invalid_archive_bytes() {
373        let dest = tempdir().unwrap();
374        let result = unpack_rl_bundle_archive(b"not a gzip archive", dest.path());
375        assert!(result.is_err());
376    }
377}