Skip to main content

mecha10_cli/services/
rl_bundle.rs

1//! RL project bundle download (LAB-2769)
2//!
3//! `mecha10 run rl --download-bundle-only` (on the trainer host) downloads and unpacks a
4//! robot-scoped RL project bundle - a tar.gz of a project's `configs/rl/` (job/task
5//! definitions) and `configs/simulation/{models,environments}` (the assets those jobs
6//! reference) - before training starts. See "RL: Vision and Phased Roadmap" doc.
7//!
8//! This bundle is deliberately separate from `mecha10 config push` (`mecha10_robot_config`'s
9//! per-key config sync): that one syncs `configs/` file-by-file to the robot-config KV service
10//! for the robot/launcher to consume at runtime, while this one is a purpose-built blob
11//! (Minio-backed via LAB-2767, not the KV store) for exactly what a remote/Kestra-dispatched
12//! training run needs.
13
14use anyhow::{Context, Result};
15use flate2::read::GzDecoder;
16use std::path::Path;
17use tar::Archive;
18
19/// URL for LAB-2767's robot-scoped RL project bundle upload endpoint. Isolated in its own
20/// function - matching `services::publish_status::build_versions_url`'s pattern - so if LAB-2767
21/// lands with a different path than assumed here, only this one function (and the tests pinned
22/// to it) need updating.
23///
24/// Assumed shape: `PUT {control_plane_url}/api/robots/{robot_id}/rl-bundle`.
25fn build_rl_bundle_url(control_plane_url: &str, robot_id: &str) -> String {
26    format!(
27        "{}/api/robots/{}/rl-bundle",
28        control_plane_url.trim_end_matches('/'),
29        robot_id
30    )
31}
32
33/// Download the RL project bundle previously uploaded for `robot_id` -
34/// LAB-2769's download side of LAB-2767's endpoint, called by `mecha10 run rl
35/// --download-bundle-only` on the trainer host. Authenticated identically to `upload_rl_bundle`
36/// (`Authorization: Bearer <api_key>` + `X-User-ID`); returns the raw tar.gz bytes for
37/// [`unpack_rl_bundle_archive`] to unpack.
38pub async fn download_rl_bundle(
39    control_plane_url: &str,
40    robot_id: &str,
41    api_key: &str,
42    user_id: &str,
43) -> Result<Vec<u8>> {
44    let url = build_rl_bundle_url(control_plane_url, robot_id);
45
46    let client = reqwest::Client::new();
47    let response = client
48        .get(&url)
49        .header("Authorization", format!("Bearer {}", api_key))
50        .header("X-User-ID", user_id)
51        .send()
52        .await
53        .context("Failed to reach mecha10-api")?;
54
55    let status = response.status();
56    if !status.is_success() {
57        let body = response.text().await.unwrap_or_default();
58        anyhow::bail!("RL bundle download failed with status {}: {}", status, body);
59    }
60
61    let bytes = response
62        .bytes()
63        .await
64        .context("Failed to read RL bundle download response body")?;
65
66    Ok(bytes.to_vec())
67}
68
69/// Unpack an RL project bundle archive (as built by [`build_rl_bundle_archive`] and returned by
70/// [`download_rl_bundle`]) into `dest_root` - entries land at `dest_root/configs/rl/...` and/or
71/// `dest_root/configs/simulation/{models,environments}/...`, mirroring the project-relative
72/// layout the archive was built from. Overwrites files already present at the destination; does
73/// not delete anything at the destination that's absent from the archive.
74pub fn unpack_rl_bundle_archive(archive_bytes: &[u8], dest_root: &Path) -> Result<()> {
75    std::fs::create_dir_all(dest_root)
76        .with_context(|| format!("Failed to create destination directory {}", dest_root.display()))?;
77
78    let decoder = GzDecoder::new(archive_bytes);
79    let mut archive = Archive::new(decoder);
80    archive
81        .unpack(dest_root)
82        .context("Failed to unpack RL project bundle")?;
83
84    Ok(())
85}
86
87#[cfg(test)]
88mod tests {
89    use super::*;
90    use flate2::write::GzEncoder;
91    use flate2::Compression;
92    use tempfile::tempdir;
93
94    /// Builds a minimal tar.gz archive containing a single `configs/rl/jobs/train.yml` entry -
95    /// a stand-in for a real RL project bundle, since bundle construction itself no longer lives
96    /// in this module (see module doc).
97    fn sample_bundle_archive() -> Vec<u8> {
98        let mut bytes = Vec::new();
99        let encoder = GzEncoder::new(&mut bytes, Compression::default());
100        let mut builder = tar::Builder::new(encoder);
101
102        let src = tempdir().unwrap();
103        std::fs::create_dir_all(src.path().join("configs/rl/jobs")).unwrap();
104        std::fs::write(src.path().join("configs/rl/jobs/train.yml"), "flow_type: train").unwrap();
105        builder
106            .append_dir_all("configs/rl", src.path().join("configs/rl"))
107            .unwrap();
108
109        let encoder = builder.into_inner().unwrap();
110        encoder.finish().unwrap();
111        bytes
112    }
113
114    #[test]
115    fn build_rl_bundle_url_scopes_by_robot_id_and_strips_trailing_slash() {
116        assert_eq!(
117            build_rl_bundle_url("https://api.example.com/", "robot-1"),
118            "https://api.example.com/api/robots/robot-1/rl-bundle"
119        );
120    }
121
122    #[tokio::test]
123    async fn download_rl_bundle_errors_when_server_unreachable() {
124        let result = download_rl_bundle("http://127.0.0.1:9", "robot-1", "test-api-key", "user-1").await;
125        assert!(result.is_err());
126    }
127
128    #[test]
129    fn unpack_rl_bundle_archive_unpacks_entries_into_the_destination() {
130        let bytes = sample_bundle_archive();
131
132        let dest = tempdir().unwrap();
133        unpack_rl_bundle_archive(&bytes, dest.path()).unwrap();
134
135        assert_eq!(
136            std::fs::read_to_string(dest.path().join("configs/rl/jobs/train.yml")).unwrap(),
137            "flow_type: train"
138        );
139    }
140
141    #[test]
142    fn unpack_rl_bundle_archive_creates_missing_destination_directory() {
143        let bytes = sample_bundle_archive();
144
145        let dest_parent = tempdir().unwrap();
146        let dest = dest_parent.path().join("does-not-exist-yet");
147        assert!(!dest.exists());
148
149        unpack_rl_bundle_archive(&bytes, &dest).unwrap();
150
151        assert!(dest.join("configs/rl/jobs/train.yml").exists());
152    }
153
154    #[test]
155    fn unpack_rl_bundle_archive_errors_on_invalid_archive_bytes() {
156        let dest = tempdir().unwrap();
157        let result = unpack_rl_bundle_archive(b"not a gzip archive", dest.path());
158        assert!(result.is_err());
159    }
160}