mecha10_cli/services/
rl_bundle.rs1use anyhow::{Context, Result};
24use flate2::read::GzDecoder;
25use flate2::write::GzEncoder;
26use flate2::Compression;
27use std::path::Path;
28use tar::{Archive, Builder};
29
30const SIMULATION_ASSET_DIRS: [&str; 2] = ["models", "environments"];
33
34pub 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
81pub fn rl_bundle_tarball_name(project_name: &str, version: &str) -> String {
86 format!("{project_name}-v{version}-rl-bundle.tar.gz")
87}
88
89fn 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
105pub 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
143pub 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
179pub 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 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 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}