consortium_builder/pipeline/
fetch.rs1use std::path::{Path, PathBuf};
44
45use consortium_cfg::Session;
46use consortium_cfg_common::config::Party;
47use tokio::process::Command;
48
49use super::application::acore_linux_triple;
50use super::firmware::remote_coprocessors;
51use super::{PipelineError, core_rust_triple, visible_core_of};
52use crate::compile::optee::OPTEE_TARGET;
53use crate::compile::utils::run_command;
54use crate::compile::web::HmiBuildCommand;
55use crate::progress::BuildReporter;
56
57#[derive(Clone, Debug, PartialEq, Eq)]
59pub struct CargoFetch {
60 pub label: String,
62 pub path: PathBuf,
64 pub target: String,
66}
67
68#[derive(Clone, Debug, PartialEq, Eq)]
70pub struct HmiFetch {
71 pub project_dir: PathBuf,
73 pub command: HmiBuildCommand,
75}
76
77#[derive(Clone, Debug, PartialEq, Eq)]
79pub struct FetchPlan {
80 pub cargo: Vec<CargoFetch>,
82 pub hmi: Option<HmiFetch>,
85}
86
87impl FetchPlan {
88 pub fn rust_targets(&self) -> Vec<String> {
94 let mut targets: Vec<String> = Vec::new();
95 for entry in &self.cargo {
96 if !targets.contains(&entry.target) {
97 targets.push(entry.target.clone());
98 }
99 }
100 targets
101 }
102}
103
104pub struct FetchConfig {
106 pub manifest_path: PathBuf,
108 pub hmi: Option<HmiFetch>,
111 pub rust_targets: bool,
115}
116
117pub fn plan_fetch(
123 session: &Session,
124 manifest_dir: &Path,
125 hmi: Option<HmiFetch>,
126) -> Result<FetchPlan, PipelineError> {
127 let mut cargo = Vec::new();
128
129 for cp in remote_coprocessors(&session.database) {
131 let core = visible_core_of(cp)?;
132 let Some((_, root)) = session
133 .config
134 .endpoints
135 .iter()
136 .find(|(party, _)| party.core() == core)
137 else {
138 continue;
139 };
140 cargo.push(CargoFetch {
141 label: core.to_string(),
142 path: manifest_dir.join(root),
143 target: core_rust_triple(core),
144 });
145 }
146
147 if session.config.tee.is_some() {
151 let root = session
152 .config
153 .endpoints
154 .get(&Party::AcoreSecure)
155 .ok_or_else(|| PipelineError::MissingEndpoint(Party::AcoreSecure.to_string()))?;
156 cargo.push(CargoFetch {
157 label: "ta".to_string(),
158 path: manifest_dir.join(root),
159 target: OPTEE_TARGET.to_string(),
160 });
161 }
162
163 let root = session
165 .config
166 .endpoints
167 .get(&Party::AcoreNonSecure)
168 .ok_or_else(|| PipelineError::MissingEndpoint(Party::AcoreNonSecure.to_string()))?;
169 cargo.push(CargoFetch {
170 label: "app".to_string(),
171 path: manifest_dir.join(root),
172 target: acore_linux_triple(&session.database.ap)?,
173 });
174
175 Ok(FetchPlan { cargo, hmi })
176}
177
178pub fn derive_hmi_fetch(
184 session: &Session,
185 manifest_dir: &Path,
186) -> Result<Option<HmiFetch>, PipelineError> {
187 use consortium_cfg_common::config::HmiEngine;
188
189 let Some(hmi) = &session.config.hmi else {
190 return Ok(None);
191 };
192 hmi.validate().map_err(PipelineError::Codegen)?;
193
194 let command = match hmi.engine {
195 HmiEngine::Cog => crate::compile::utils::resolve_hmi_fetch_command(),
196 HmiEngine::Pocket => crate::compile::utils::resolve_pocket_fetch_command(),
197 }
198 .map_err(PipelineError::Codegen)?;
199
200 Ok(Some(HmiFetch {
201 project_dir: manifest_dir.join(&hmi.source),
202 command,
203 }))
204}
205
206pub async fn run(config: FetchConfig) -> Result<FetchPlan, PipelineError> {
208 run_inner(config, None).await
209}
210
211pub async fn run_with_reporter(
213 config: FetchConfig,
214 reporter: &dyn BuildReporter,
215) -> Result<FetchPlan, PipelineError> {
216 run_inner(config, Some(reporter)).await
217}
218
219async fn run_inner(
220 config: FetchConfig,
221 reporter: Option<&dyn BuildReporter>,
222) -> Result<FetchPlan, PipelineError> {
223 let session = super::load_session(&config.manifest_path).await?;
224 let manifest_dir = config
225 .manifest_path
226 .parent()
227 .map(Path::to_path_buf)
228 .unwrap_or_else(|| PathBuf::from("."));
229
230 let plan = plan_fetch(&session, &manifest_dir, config.hmi)?;
231 let targets = plan.rust_targets();
232 let install_targets = config.rust_targets && !targets.is_empty();
233
234 let total = usize::from(install_targets) + plan.cargo.len() + usize::from(plan.hmi.is_some());
235 let mut steps = super::StepRunner::new(reporter, total);
236
237 if install_targets {
238 let detail = targets.join(" ");
239 steps
240 .run("Install Rust targets", Some(&detail), async {
241 install_rust_targets(&targets, reporter).await
242 })
243 .await?;
244 }
245
246 for entry in &plan.cargo {
247 let detail = format!("{} ({})", entry.label, entry.target);
248 steps
249 .run("Fetch dependencies", Some(&detail), async {
250 cargo_fetch(entry, reporter).await
251 })
252 .await?;
253 }
254
255 if let Some(hmi) = &plan.hmi {
256 let detail = hmi.project_dir.display().to_string();
257 steps
258 .run("Install HMI dependencies", Some(&detail), async {
259 hmi_install(hmi, reporter).await
260 })
261 .await?;
262 }
263
264 Ok(plan)
265}
266
267async fn cargo_fetch(
274 entry: &CargoFetch,
275 reporter: Option<&dyn BuildReporter>,
276) -> Result<(), PipelineError> {
277 let cargo = crate::compile::utils::resolve_cargo()
278 .await
279 .map_err(|message| PipelineError::Fetch {
280 label: entry.label.clone(),
281 target: entry.target.clone(),
282 message,
283 })?;
284
285 let mut cmd = Command::new(&cargo);
286 cmd.arg("fetch")
287 .arg("--target")
288 .arg(&entry.target)
289 .current_dir(&entry.path);
290
291 let status = run_command(&mut cmd, reporter)
292 .await
293 .map_err(|e| PipelineError::Fetch {
294 label: entry.label.clone(),
295 target: entry.target.clone(),
296 message: e.to_string(),
297 })?;
298
299 if !status.success() {
300 return Err(PipelineError::Fetch {
301 label: entry.label.clone(),
302 target: entry.target.clone(),
303 message: format!("`cargo fetch` exited with status: {status}"),
304 });
305 }
306 Ok(())
307}
308
309async fn install_rust_targets(
316 targets: &[String],
317 reporter: Option<&dyn BuildReporter>,
318) -> Result<(), PipelineError> {
319 let mut cmd = Command::new("rustup");
320 cmd.args(["target", "add", "--toolchain", "stable"])
321 .args(targets);
322
323 let status = match run_command(&mut cmd, reporter).await {
324 Ok(status) => status,
325 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
326 eprintln!(
327 "note: rustup not found; skipping `rustup target add {}`. Ensure the \
328 standard library for these targets is installed by other means.",
329 targets.join(" ")
330 );
331 return Ok(());
332 }
333 Err(e) => {
334 return Err(PipelineError::RustTarget {
335 targets: targets.join(" "),
336 message: e.to_string(),
337 });
338 }
339 };
340
341 if !status.success() {
342 return Err(PipelineError::RustTarget {
343 targets: targets.join(" "),
344 message: format!("`rustup target add` exited with status: {status}"),
345 });
346 }
347 Ok(())
348}
349
350async fn hmi_install(
352 hmi: &HmiFetch,
353 reporter: Option<&dyn BuildReporter>,
354) -> Result<(), PipelineError> {
355 let dir = hmi.project_dir.display().to_string();
356 let (program, args) = hmi.command.program_args();
357 if program.is_empty() {
358 return Err(PipelineError::HmiFetch {
359 dir,
360 message: "HMI install command is empty".to_string(),
361 });
362 }
363
364 match tokio::fs::metadata(&hmi.project_dir).await {
365 Ok(meta) if meta.is_dir() => {}
366 _ => {
367 return Err(PipelineError::HmiFetch {
368 dir,
369 message: "project directory not found".to_string(),
370 });
371 }
372 }
373
374 let mut cmd = Command::new(program);
375 cmd.args(&args).current_dir(&hmi.project_dir);
376
377 let status = run_command(&mut cmd, reporter)
378 .await
379 .map_err(|e| PipelineError::HmiFetch {
380 dir: dir.clone(),
381 message: format!("failed to launch `{program}`: {e}"),
382 })?;
383
384 if !status.success() {
385 return Err(PipelineError::HmiFetch {
386 dir,
387 message: format!("`{program}` exited with status: {status}"),
388 });
389 }
390 Ok(())
391}
392
393#[cfg(test)]
394mod tests {
395 use super::*;
396
397 const STM32: &str = include_str!("../../../consortium-cfg/Consortium.example.stm32mp257.toml");
398
399 fn session() -> Session {
400 Session::from_toml(STM32).expect("example manifest loads")
401 }
402
403 #[test]
404 fn plans_one_fetch_per_cargo_project() {
405 let plan = plan_fetch(&session(), Path::new("/proj"), None).expect("plan succeeds");
406
407 let entries: Vec<(&str, &str)> = plan
410 .cargo
411 .iter()
412 .map(|e| (e.label.as_str(), e.target.as_str()))
413 .collect();
414 assert_eq!(
415 entries,
416 vec![
417 ("cm33", "thumbv8m.main-none-eabihf"),
418 ("ta", "aarch64-unknown-linux-gnu"),
419 ("app", "aarch64-unknown-linux-gnu"),
420 ]
421 );
422
423 assert_eq!(plan.cargo[0].path, Path::new("/proj/./firmware"));
425 assert_eq!(plan.cargo[1].path, Path::new("/proj/./ta"));
426 assert_eq!(plan.cargo[2].path, Path::new("/proj/./app"));
427
428 assert!(plan.hmi.is_none());
429 }
430
431 #[test]
432 fn rust_targets_are_deduplicated_in_first_use_order() {
433 let plan = plan_fetch(&session(), Path::new("/proj"), None).expect("plan succeeds");
436 assert_eq!(
437 plan.rust_targets(),
438 vec!["thumbv8m.main-none-eabihf", "aarch64-unknown-linux-gnu"]
439 );
440 }
441
442 #[test]
443 fn plan_carries_the_hmi_install_through() {
444 let hmi = HmiFetch {
445 project_dir: PathBuf::from("/proj/website"),
446 command: HmiBuildCommand::Custom(vec!["pnpm".into(), "install".into()]),
447 };
448 let plan =
449 plan_fetch(&session(), Path::new("/proj"), Some(hmi.clone())).expect("plan succeeds");
450 assert_eq!(plan.hmi, Some(hmi));
451 }
452
453 #[test]
454 fn plan_omits_the_ta_without_a_tee_section() {
455 let manifest = "\
458 [profile]\n\
459 chip = \"stm32mp257fak3\"\n\
460 shared = \"./shared\"\n\
461 deploy_root = \"/opt/consortium\"\n\
462 \n\
463 [endpoints]\n\
464 linux = \"./app\"\n";
465 let session = Session::from_toml(manifest).expect("manifest loads");
466 let plan = plan_fetch(&session, Path::new("/proj"), None).expect("plan succeeds");
467 let labels: Vec<&str> = plan.cargo.iter().map(|e| e.label.as_str()).collect();
468 assert_eq!(labels, vec!["app"]);
469 }
470
471 #[test]
472 fn plan_errors_without_an_application_endpoint() {
473 let manifest = "\
474 [profile]\n\
475 chip = \"stm32mp257fak3\"\n\
476 shared = \"./shared\"\n\
477 deploy_root = \"/opt/consortium\"\n";
478 let session = Session::from_toml(manifest).expect("manifest loads");
479 let err = plan_fetch(&session, Path::new("/proj"), None)
480 .expect_err("a build always needs the Linux application");
481 assert!(matches!(err, PipelineError::MissingEndpoint(_)));
482 }
483
484 #[tokio::test]
485 async fn hmi_install_runs_the_command_in_the_project_dir() {
486 let dir = std::env::var("SCRATCH_DIR")
487 .ok()
488 .map(PathBuf::from)
489 .unwrap_or_else(std::env::temp_dir)
490 .join(format!("consortium-fetch-hmi-{}", std::process::id()));
491 let _ = std::fs::remove_dir_all(&dir);
492 std::fs::create_dir_all(&dir).expect("create temp dir");
493
494 let hmi = HmiFetch {
495 project_dir: dir.clone(),
496 command: HmiBuildCommand::Custom(vec![
497 "sh".into(),
498 "-c".into(),
499 "printf x > installed".into(),
500 ]),
501 };
502 hmi_install(&hmi, None).await.expect("install succeeds");
503 assert!(dir.join("installed").exists());
504
505 let failing = HmiFetch {
507 project_dir: dir.clone(),
508 command: HmiBuildCommand::Custom(vec!["false".into()]),
509 };
510 let err = hmi_install(&failing, None)
511 .await
512 .expect_err("non-zero exit is an error");
513 assert!(matches!(err, PipelineError::HmiFetch { .. }));
514
515 let _ = std::fs::remove_dir_all(&dir);
516 }
517
518 #[tokio::test]
519 async fn hmi_install_errors_when_project_dir_missing() {
520 let hmi = HmiFetch {
521 project_dir: PathBuf::from("/nonexistent/consortium-website"),
522 command: HmiBuildCommand::Custom(vec!["true".into()]),
523 };
524 let err = hmi_install(&hmi, None)
525 .await
526 .expect_err("missing project dir is an error");
527 assert!(matches!(err, PipelineError::HmiFetch { .. }));
528 }
529}