mecha10_cli/services/
rl_bundle.rs1use anyhow::{Context, Result};
15use flate2::read::GzDecoder;
16use std::path::Path;
17use tar::Archive;
18
19fn 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
33pub 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
69pub 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 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}