Skip to main content

consortium_builder/pipeline/
mod.rs

1// Copyright 2026 Ethan Wu
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14//
15// SPDX-License-Identifier: Apache-2.0
16
17//! End-to-end build pipeline: `Consortium.toml` → `dist/`.
18//!
19//! The eight stages are:
20//!
21//! 1. compile firmware for remote coprocessors ([`firmware`]);
22//! 2. build the defmt "ELF map" the app embeds ([`elf_map`]);
23//! 3. build the HMI frontend (`compile::web`);
24//! 4. compile + sign the OP-TEE TA ([`tee`]);
25//! 5. compile the Linux application ([`application`]);
26//! 6. render the systemd unit (`compile::deploy::systemd`);
27//! 7. assemble `dist/` ([`dist`]);
28//! 8. optionally hand off to bitbake ([`bitbake`]).
29//!
30//! [`plan`] resolves every stage's inputs without running any build (a dry run
31//! that threads the data flow); [`run`] plans, then executes.
32//!
33//! [`check`] is the `cargo check` of the pipeline: it plans the same way, writes
34//! the generated modules, and type-checks every Cargo project without building,
35//! linking, or staging anything.
36//!
37//! [`fetch`] sits beside them rather than inside them: it resolves the same Cargo
38//! projects and downloads their dependencies ahead of time, so a later [`run`]
39//! with [`PipelineConfig::offline`] never reaches the network.
40
41pub mod application;
42pub mod bitbake;
43pub mod check;
44pub mod dist;
45pub mod dtc;
46pub mod dts;
47pub mod elf_map;
48pub mod fetch;
49pub mod firmware;
50pub mod tee;
51
52use std::collections::HashMap;
53use std::path::{Path, PathBuf};
54
55use consortium_cfg::Session;
56use consortium_cfg_common::config::{HmiEngine, VisibleCore, Workspace, XcoreName};
57use consortium_cfg_common::database::Coprocessor;
58use thiserror::Error;
59
60use crate::compile::application::{ApplicationCompileError, ApplicationCompiler};
61use crate::compile::deploy::systemd::{self, SystemdRestartPolicy, SystemdUnitConfig};
62use crate::compile::env::CstiWorkspace;
63use crate::compile::firmware::{FirmwareCompileError, FirmwareCompiler};
64use crate::compile::optee::{OpteeCompileError, OpteeCompiler, OpteeConfiguration};
65use crate::compile::web::{HmiCompileError, HmiCompiler, HmiConfiguration};
66use crate::progress::{BuildReporter, BuildStep};
67
68use application::ApplicationBuildPlan;
69use bitbake::{BitbakeAction, BitbakeError, RecipeSpec};
70use dist::{DistAssemblyError, DistLayout};
71use firmware::FirmwareBuildPlan;
72use tee::TeeToolchain;
73
74/// The file name the generated per-core init module is written as inside a crate
75/// (`src/<GENERATED_MODULE>`). The crate is expected to `include!`/`mod` it.
76const GENERATED_MODULE: &str = "consortium.gen.rs";
77const GENERATED_MEMORY_X: &str = "memory.x";
78
79/// Aggregate error for the build pipeline.
80#[derive(Error, Debug)]
81pub enum PipelineError {
82    /// Reading the manifest file failed.
83    #[error("reading manifest {path}: {source}")]
84    ManifestRead {
85        /// The manifest path.
86        path: String,
87        /// The underlying error.
88        source: std::io::Error,
89    },
90
91    /// `[profile] workspace = "unified"`, but the manifest directory holds no
92    /// Cargo workspace root.
93    #[error(
94        "[profile] workspace = \"unified\" requires a workspace root Cargo.toml beside the manifest, but {0} {1}"
95    )]
96    UnifiedWorkspace(String, &'static str),
97
98    /// Parsing/validating the manifest or lowering it failed.
99    #[error(transparent)]
100    Cfg(#[from] Box<consortium_cfg::CfgError>),
101
102    /// Firmware compilation failed.
103    #[error(transparent)]
104    Firmware(#[from] FirmwareCompileError),
105
106    /// Application compilation failed.
107    #[error(transparent)]
108    Application(#[from] ApplicationCompileError),
109
110    /// OP-TEE TA compilation/signing failed.
111    #[error(transparent)]
112    Optee(#[from] OpteeCompileError),
113
114    /// HMI frontend compilation failed.
115    #[error(transparent)]
116    Hmi(#[from] HmiCompileError),
117
118    /// Assembling the `dist/` tree failed.
119    #[error(transparent)]
120    Dist(#[from] DistAssemblyError),
121
122    #[error(transparent)]
123    Dtc(#[from] dtc::DtcError),
124
125    /// The bitbake hand-off failed.
126    #[error(transparent)]
127    Bitbake(#[from] BitbakeError),
128
129    /// Writing a generated module into a crate failed.
130    #[error("writing generated module to {path}: {source}")]
131    GeneratedWrite {
132        /// The generated-module path.
133        path: String,
134        /// The underlying error.
135        source: std::io::Error,
136    },
137
138    /// A `[dbg.<core>]` core was not built in Stage 1 (typically a manager core).
139    #[error(
140        "debug core '{0}' has no compiled firmware to embed: `[dbg.{0}]` names a \
141         core that is not a remote firmware target"
142    )]
143    DbgCoreNotBuilt(String),
144
145    /// A required `[endpoints]` entry is missing.
146    #[error("manifest declares no endpoint for the required party '{0}'")]
147    MissingEndpoint(String),
148
149    /// A `[tee]` section is present but no OP-TEE toolchain was supplied.
150    #[error(
151        "manifest has a [tee] section but no OP-TEE toolchain was provided \
152         (set TA_DEV_KIT_DIR or pass a toolchain)"
153    )]
154    MissingTeeToolchain,
155
156    /// A core name could not be resolved.
157    #[error("unknown core '{0}'")]
158    UnknownCore(String),
159
160    /// Code generation produced invalid tokens or a path could not be resolved.
161    #[error("codegen error: {0}")]
162    Codegen(String),
163
164    /// A `cargo fetch` invocation failed.
165    #[error("fetching dependencies for {label} ({target}): {message}")]
166    Fetch {
167        /// The artifact whose crate was being fetched (a core name, `ta`, `app`).
168        label: String,
169        /// The Rust target triple the fetch was scoped to.
170        target: String,
171        /// The underlying failure.
172        message: String,
173    },
174
175    /// Installing a target's Rust standard library failed.
176    #[error("installing rust target(s) {targets}: {message}")]
177    RustTarget {
178        /// The space-separated triples that were requested.
179        targets: String,
180        /// The underlying failure.
181        message: String,
182    },
183
184    /// Installing the HMI frontend's dependencies failed.
185    #[error("installing HMI dependencies in {dir}: {message}")]
186    HmiFetch {
187        /// The frontend project directory the installer ran in.
188        dir: String,
189        /// The underlying failure.
190        message: String,
191    },
192}
193
194impl From<consortium_cfg::CfgError> for PipelineError {
195    fn from(error: consortium_cfg::CfgError) -> Self {
196        Self::Cfg(Box::new(error))
197    }
198}
199
200/// How cargo is invoked for every Rust artifact the pipeline builds.
201///
202/// The firmware, TA, and application are three separate cargo projects; these are
203/// the flags that must agree across all of them, carried as one value so a caller
204/// cannot set them inconsistently.
205#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
206pub struct CargoOptions {
207    /// Build in release mode.
208    pub release: bool,
209    /// Pass `--offline`, restricting cargo to its local registry cache. A build
210    /// that has not been preceded by [`fetch`] will fail rather than reach the
211    /// network, which is the point: it makes the missing dependency loud.
212    pub offline: bool,
213}
214
215/// All parameters for a pipeline run.
216pub struct PipelineConfig {
217    /// Path to the `Consortium.toml` manifest.
218    pub manifest_path: PathBuf,
219    /// Root of the `dist/` staging tree to assemble.
220    pub dist_root: PathBuf,
221    /// Authoritative Linux kernel checkout. All BSP DTS/DTSI inputs and
222    /// dt-bindings are read from this tree; Consortium bundles none of them.
223    pub linux: PathBuf,
224    /// Build in release mode.
225    pub release: bool,
226    /// Build from cargo's local registry cache only (`--offline`), never the
227    /// network. Populate the cache first with [`fetch::run_with_reporter`].
228    pub offline: bool,
229    /// HMI frontend build parameters, or `None` to skip the HMI stage. Derive
230    /// with [`derive_hmi_configuration`].
231    pub hmi: Option<HmiConfiguration>,
232    /// OP-TEE toolchain, or `None` (an error if the manifest declares `[tee]`).
233    pub tee_toolchain: Option<TeeToolchain>,
234    /// Sign the OP-TEE TA after building it. `false` (from `--skip-tee-sign`)
235    /// stops at the unsigned ELF, so no signer script or keys are needed.
236    pub sign_tee: bool,
237    /// Require the selected BSP DTS source to declare every manifest remoteproc
238    /// core's node. `false` (from `--skip-remoteproc-check`) downgrades a
239    /// missing node to a warning and skips DTS fixups for that core instead of
240    /// failing the build — useful when the `--linux` checkout does not yet
241    /// carry a vendor's out-of-tree remoteproc binding (the rendered DTS will
242    /// not actually be able to load that firmware until it does).
243    pub strict_remoteproc: bool,
244    /// Bitbake hand-off actions run in order after `dist/` is assembled. Empty
245    /// skips the stage. Multiple actions compose: e.g. stage `dist/` into a
246    /// recipe's `files/` dir *and* record the deferred `bitbake` command
247    /// (dry-run) when the recipe is built later on a separate Yocto host.
248    pub bitbake: Vec<BitbakeAction>,
249}
250
251impl PipelineConfig {
252    /// The cargo flags every Rust stage of this run shares.
253    pub fn cargo(&self) -> CargoOptions {
254        CargoOptions {
255            release: self.release,
256            offline: self.offline,
257        }
258    }
259}
260
261/// The fully resolved plan for a pipeline run: every stage's inputs threaded
262/// together without running any build.
263pub struct PipelinePlan {
264    /// Directory containing the manifest (all relative paths resolve against it).
265    pub manifest_dir: PathBuf,
266    /// Rust path for the shared message-type crate used by codegen.
267    pub ty_crate: syn::Path,
268    /// One plan per remote coprocessor firmware.
269    pub firmware: Vec<FirmwareBuildPlan>,
270    /// Expected build-host ELF path per firmware core.
271    pub firmware_outputs: HashMap<VisibleCore, PathBuf>,
272    /// The debug "ELF map": firmware ELF path per `[dbg.<core>]` core.
273    pub dbg_elfs: HashMap<VisibleCore, PathBuf>,
274    /// HMI build parameters, when the HMI stage runs.
275    pub hmi: Option<HmiConfiguration>,
276    /// The HMI dist directory the frontend build produces, when it runs.
277    pub hmi_dist: Option<PathBuf>,
278    /// TA build parameters, when the manifest declares `[tee]`.
279    pub ta: Option<OpteeConfiguration>,
280    /// The application build plan and its generated AP init module.
281    pub application: ApplicationBuildPlan,
282    /// The Linux device tree to write under `dist/dts/linux/`, as
283    /// `(file_name, contents)`: a merged `.dts` (default) or a `.dtso` overlay.
284    /// Empty when the manifest declares neither UIO nor remoteproc devices.
285    pub dts_files: Vec<(String, String)>,
286    /// Exact BSP DTS sources used to produce `dts_files`.
287    pub dts_source_closure: Vec<(String, String)>,
288    /// The kernel-side files to write under `dist/kernel/`, as
289    /// `(file_name, contents)`: the UIO config fragment and, when `[profile]
290    /// kernel_recipe` is set, the `.bbappend` that merges it. Empty when neither
291    /// UIO nor remoteproc needs kernel configuration.
292    pub kernel_files: Vec<(String, String)>,
293    /// The boot-args files to write under `dist/boot/`, as
294    /// `(file_name, contents)`: the family-specific bootloader snippet(s) that
295    /// put `uio_pdrv_genirq.of_id=generic-uio` on the kernel command line, plus a
296    /// README. Empty when the manifest declares no UIO devices.
297    pub boot_files: Vec<(String, String)>,
298    /// The systemd unit to render.
299    pub systemd: SystemdUnitConfig,
300}
301
302/// Loads and validates the manifest at `manifest_path`.
303pub async fn load_session(manifest_path: &Path) -> Result<Session, PipelineError> {
304    consortium_log::trace!("loading manifest {}", manifest_path.display());
305    let toml = tokio::fs::read_to_string(manifest_path)
306        .await
307        .map_err(|source| {
308            consortium_log::trace!("manifest read failed: {}", manifest_path.display());
309            PipelineError::ManifestRead {
310                path: manifest_path.display().to_string(),
311                source,
312            }
313        })?;
314    Ok(Session::from_toml(&toml)?)
315}
316
317/// Resolves `[profile] workspace` against the manifest directory into the
318/// `CSTI_*` directories every endpoint build is given.
319///
320/// Both directories are made absolute (lexically). `CSTI_MCU_PATH` is set only
321/// when the manifest declares exactly one firmware crate. A unified workspace
322/// requires `<manifest dir>/Cargo.toml` to exist and declare `[workspace]`, since
323/// every endpoint's output then lands in the manifest directory's `target/`.
324pub fn resolve_workspace(
325    session: &Session,
326    manifest_dir: &Path,
327) -> Result<CstiWorkspace, PipelineError> {
328    let absolute = |p: &Path| std::path::absolute(p).unwrap_or_else(|_| p.to_path_buf());
329    let manifest_dir = absolute(manifest_dir);
330    let layout = session.config.profile.workspace;
331
332    if layout == Workspace::Unified {
333        let cargo_toml = manifest_dir.join("Cargo.toml");
334        let shown = cargo_toml.display().to_string();
335        let contents = std::fs::read_to_string(&cargo_toml)
336            .map_err(|_| PipelineError::UnifiedWorkspace(shown.clone(), "does not exist"))?;
337        let is_workspace = contents
338            .parse::<toml::Table>()
339            .is_ok_and(|table| table.contains_key("workspace"));
340        if !is_workspace {
341            return Err(PipelineError::UnifiedWorkspace(
342                shown,
343                "has no [workspace] table",
344            ));
345        }
346    }
347
348    let firmware = firmware::firmware_endpoints(session)?;
349    let mcu_dir = match firmware.as_slice() {
350        [(_, _, root)] => Some(absolute(&manifest_dir.join(root))),
351        _ => None,
352    };
353
354    Ok(CstiWorkspace {
355        layout,
356        manifest_dir,
357        mcu_dir,
358    })
359}
360
361/// Derives HMI build parameters from the manifest's `[hmi]` section, or `None`.
362///
363/// Resolves the frontend project (`[hmi].source`) and dist (`[hmi].build`)
364/// directories against the manifest directory and the build command from the
365/// environment/PATH.
366pub fn derive_hmi_configuration(
367    session: &Session,
368    manifest_dir: &Path,
369) -> Result<Option<HmiConfiguration>, PipelineError> {
370    let Some(hmi) = &session.config.hmi else {
371        return Ok(None);
372    };
373    hmi.validate().map_err(PipelineError::Codegen)?;
374
375    let project_dir = manifest_dir.join(&hmi.source);
376    let dist_dir = manifest_dir.join(&hmi.build);
377    let command = match hmi.engine {
378        HmiEngine::Cog => {
379            crate::compile::utils::resolve_hmi_build_command().map_err(PipelineError::Codegen)?
380        }
381        HmiEngine::Pocket => {
382            // `validate` guaranteed `app` is present and non-empty.
383            let app = hmi.app.as_deref().unwrap_or_default();
384            crate::compile::utils::resolve_pocket_build_command(app, &dist_dir)
385                .map_err(PipelineError::Codegen)?
386        }
387    };
388    Ok(Some(HmiConfiguration {
389        engine: hmi.engine,
390        app: hmi.app.clone(),
391        project_dir,
392        dist_dir,
393        command,
394    }))
395}
396
397/// Resolves every stage's inputs without building (a dry run).
398pub async fn plan(
399    session: &Session,
400    config: &PipelineConfig,
401) -> Result<PipelinePlan, PipelineError> {
402    let manifest_dir = config
403        .manifest_path
404        .parent()
405        .map(Path::to_path_buf)
406        .unwrap_or_else(|| PathBuf::from("."));
407
408    let ty_crate = resolve_ty_crate(session, &manifest_dir).await?;
409    let cargo = config.cargo();
410    let workspace = resolve_workspace(session, &manifest_dir)?;
411
412    // Stage 1: firmware plans + expected ELF outputs.
413    let mut firmware = firmware::plan_firmware_builds(session, &workspace, cargo, &ty_crate)?;
414    for plan in &mut firmware {
415        plan.config.strict_remoteproc = config.strict_remoteproc;
416    }
417    let mut firmware_outputs = HashMap::new();
418    for plan in &firmware {
419        let elf = firmware::firmware_elf_path(&plan.config).await?;
420        firmware_outputs.insert(plan.core, elf);
421    }
422
423    // Stage 2: the defmt ELF map (fails fast if a dbg core was not built). The AP
424    // decodes defmt from the `*_original.elf` image (the objcopy-stripped deploy
425    // ELF has no `.defmt` section), so map each raw output to its original sibling.
426    let ir = session.lower()?;
427    let dbg_sources: HashMap<VisibleCore, PathBuf> = firmware_outputs
428        .iter()
429        .map(|(core, raw)| (*core, crate::compile::firmware::original_elf_path(raw)))
430        .collect();
431    let dbg_elfs = elf_map::resolve_dbg_elf_paths(&dbg_sources, ir.dbg.rings().keys().copied())?;
432
433    // Stage 3: HMI dist directory (built later; recorded here).
434    let hmi_dist = config.hmi.as_ref().map(|h| h.dist_dir.clone());
435
436    // Stage 4: TA plan.
437    let ta = tee::plan_ta_build(
438        session,
439        &workspace,
440        config.tee_toolchain.as_ref(),
441        cargo,
442        config.sign_tee,
443    )?;
444
445    // Stage 5: application plan (AP module embeds the resolved firmware ELFs).
446    let application = application::plan_application_build(
447        session,
448        &workspace,
449        hmi_dist.as_deref(),
450        &dbg_elfs,
451        cargo,
452        &ty_crate,
453    )?;
454
455    // Stage 6: the Linux device tree (merged `.dts` or overlay `.dtso`, built on
456    // the vendor base for `[profile] chip`/`devkit`) plus the kernel-side files
457    // enabling its drivers, and the boot-args files — all from the lowered IR.
458    let profile = &session.config.profile;
459    let dts_cfg = &session.config.dts;
460    // The coprocessor cores the build targets, so remoteproc fixups stay scoped.
461    let target_cores: Vec<String> = firmware.iter().map(|p| p.core.to_string()).collect();
462    let (dts_files, dts_source_closure) = match dts::render_linux_dts(
463        &profile.chip,
464        profile.devkit.as_deref(),
465        dts_cfg.base.as_deref(),
466        dts_cfg.mode,
467        &ir,
468        &target_cores,
469        &config.linux,
470        config.strict_remoteproc,
471    )? {
472        Some(rendered) => {
473            consortium_log::trace!(
474                "rendered linux dts {} with {} warnings",
475                rendered.file_name,
476                rendered.warnings.len()
477            );
478            for warning in &rendered.warnings {
479                consortium_log::trace!("dts warning: {}", warning);
480                eprintln!("warning: {warning}");
481            }
482            (
483                vec![(rendered.file_name, rendered.contents)],
484                rendered.source_closure,
485            )
486        }
487        None => {
488            consortium_log::trace!("no UIO or remoteproc devices; no linux dts rendered");
489            (Vec::new(), Vec::new())
490        }
491    };
492
493    // The kernel config fragment plus, when the manifest names a kernel recipe, a
494    // `.bbappend` that merges it into the kernel build. Both land in `dist/kernel/`.
495    let mut kernel_files = dts::render_kernel_kconfig(&profile.chip, &ir)
496        .map(|contents| vec![(dts::KCONFIG_NAME.to_string(), contents)])
497        .unwrap_or_default();
498    if let Some(kernel_recipe) = &session.config.profile.kernel_recipe {
499        let fragment_names: Vec<String> = kernel_files.iter().map(|(n, _)| n.clone()).collect();
500        if let Some(bbappend) = dts::render_kernel_bbappend(&fragment_names) {
501            kernel_files.push((dts::kernel_bbappend_name(kernel_recipe), bbappend));
502        }
503    }
504
505    // The family-specific boot-args files under `dist/boot/`.
506    let boot_files = dts::render_boot(&profile.chip, &ir);
507
508    // Stage 7: systemd unit.
509    let systemd = systemd_config(session, &application.config.path).await?;
510
511    Ok(PipelinePlan {
512        manifest_dir,
513        ty_crate,
514        firmware,
515        firmware_outputs,
516        dbg_elfs,
517        hmi: config.hmi.clone(),
518        hmi_dist,
519        ta,
520        application,
521        dts_files,
522        dts_source_closure,
523        kernel_files,
524        boot_files,
525        systemd,
526    })
527}
528
529/// Runs the full pipeline, returning the assembled `dist/` root.
530pub async fn run(config: PipelineConfig) -> Result<PathBuf, PipelineError> {
531    run_inner(config, None).await
532}
533
534/// Runs the full pipeline while reporting step progress and subprocess output.
535pub async fn run_with_reporter(
536    config: PipelineConfig,
537    reporter: &dyn BuildReporter,
538) -> Result<PathBuf, PipelineError> {
539    run_inner(config, Some(reporter)).await
540}
541
542async fn run_inner(
543    config: PipelineConfig,
544    reporter: Option<&dyn BuildReporter>,
545) -> Result<PathBuf, PipelineError> {
546    consortium_log::trace!("pipeline run: manifest {}", config.manifest_path.display());
547    let session = load_session(&config.manifest_path).await?;
548    let plan = plan(&session, &config).await?;
549    consortium_log::trace!(
550        "pipeline plan: {} firmware, ta={}, hmi={}, {} bitbake",
551        plan.firmware.len(),
552        plan.ta.is_some(),
553        config.hmi.is_some(),
554        config.bitbake.len()
555    );
556
557    let total_steps = plan.firmware.len()
558        + usize::from(config.hmi.is_some())
559        + usize::from(plan.ta.is_some())
560        + 2
561        + config.bitbake.len();
562    let mut steps = StepRunner::new(reporter, total_steps);
563
564    // The deploy (objcopy-processed) ELF per core, staged into `dist/firmware/`.
565    // The AP already embedded the `*_original.elf` at plan time (Stage 2/5); the
566    // unstripped original is also staged into `dist/firmware/` so the integrator
567    // keeps its `.defmt` section for host-side defmt table parsing.
568    let mut firmware_outputs = HashMap::new();
569    let mut firmware_originals = HashMap::new();
570    for fw in &plan.firmware {
571        let detail = format!("{} ({})", fw.core, fw.config.target);
572        let (deploy, original) = steps
573            .run("Build firmware", Some(&detail), async {
574                write_generated_module(&fw.config.path, &fw.generated).await?;
575                write_generated_memory_x(&fw.config).await?;
576                let artifacts =
577                    FirmwareCompiler::build_with_reporter(fw.config.clone(), reporter).await?;
578                Ok((artifacts.deploy, artifacts.original))
579            })
580            .await?;
581        firmware_outputs.insert(fw.core, deploy);
582        firmware_originals.insert(fw.core, original);
583    }
584
585    let hmi_dist = match &config.hmi {
586        Some(hmi) => {
587            let detail = hmi.project_dir.display().to_string();
588            Some(
589                steps
590                    .run("Build HMI", Some(&detail), async {
591                        let dist = HmiCompiler::build_with_reporter(hmi.clone(), reporter).await?;
592                        Ok(dist)
593                    })
594                    .await?,
595            )
596        }
597        None => None,
598    };
599
600    let ta = match plan.ta {
601        Some(ta) => {
602            let detail = if ta.sign {
603                "signed trusted application"
604            } else {
605                "unsigned trusted application"
606            };
607            Some(
608                steps
609                    .run("Build TEE", Some(detail), async {
610                        let ta = OpteeCompiler::build_with_reporter(ta, reporter).await?;
611                        Ok(ta)
612                    })
613                    .await?,
614            )
615        }
616        None => None,
617    };
618
619    let app_detail = plan.application.config.target.clone();
620    let application = steps
621        .run("Build application", Some(&app_detail), async {
622            write_generated_module(&plan.application.config.path, &plan.application.generated)
623                .await?;
624            let app =
625                ApplicationCompiler::build_with_reporter(plan.application.config.clone(), reporter)
626                    .await?;
627            Ok(app)
628        })
629        .await?;
630
631    let systemd_units = vec![(
632        plan.systemd.file_name(),
633        systemd::render_unit(&plan.systemd),
634    )];
635
636    let dist_detail = config.dist_root.display().to_string();
637    let layout = DistLayout::new(&config.dist_root);
638    steps
639        .run("Assemble dist", Some(&dist_detail), async {
640            let inputs = dist::DistAssemblyInputs {
641                firmware: &firmware_outputs,
642                firmware_originals: &firmware_originals,
643                application: &application,
644                ta: ta.as_deref(),
645                hmi: config
646                    .hmi
647                    .as_ref()
648                    .zip(hmi_dist.as_deref())
649                    .map(|(cfg, dist)| dist::HmiStaging {
650                        engine: cfg.engine,
651                        app: cfg.app.as_deref(),
652                        dist,
653                    }),
654                dts_files: &plan.dts_files,
655                kconfig_fragments: &plan.kernel_files,
656                boot_files: &plan.boot_files,
657                systemd_units: &systemd_units,
658            };
659            dist::assemble(&layout, inputs).await?;
660            Ok(())
661        })
662        .await?;
663
664    // Compile the emitted device tree against the same `--linux` checkout from
665    // which its BSP sources were read.
666    if let Some((dts_name, _)) = plan.dts_files.first() {
667        let arch = dtc::arch_dir(&plan.application.config.target);
668        let dts_dir = layout.dts_linux_dir();
669        let detail = format!("dtc {dts_name}");
670        steps
671            .run("Compile device tree", Some(&detail), async {
672                let out = dtc::compile_device_tree(
673                    &dts_dir,
674                    dts_name,
675                    &plan.dts_source_closure,
676                    &config.linux,
677                    arch,
678                )
679                .await?;
680                Ok(out)
681            })
682            .await?;
683    }
684
685    for action in &config.bitbake {
686        // A dry-run invoke emits a self-contained `.bb` recipe describing how to
687        // install the assembled `dist/` tree, so a separate Yocto host can build
688        // it without re-running the Consortium pipeline.
689        let recipe_spec = match action {
690            BitbakeAction::InvokeBitbake {
691                recipe, dry_run, ..
692            } if *dry_run => Some(recipe_spec(
693                recipe,
694                &session.config.profile.deploy_root,
695                &application,
696                &firmware_outputs,
697                ta.as_deref(),
698                hmi_dist.as_deref(),
699                &plan.systemd.file_name(),
700                &plan.dts_files,
701                &plan.boot_files,
702            )?),
703            _ => None,
704        };
705
706        let (label, detail) = match action {
707            BitbakeAction::CopyIntoRecipe { recipe_files_dir } => (
708                "Stage into recipe",
709                format!("copy to {}", recipe_files_dir.display()),
710            ),
711            BitbakeAction::InvokeBitbake {
712                build_dir,
713                recipe,
714                dry_run,
715            } => {
716                let dry = if *dry_run {
717                    " (dry-run, recipe only)"
718                } else {
719                    ""
720                };
721                (
722                    "Run bitbake",
723                    format!("{recipe} in {}{dry}", build_dir.display()),
724                )
725            }
726        };
727        steps
728            .run(label, Some(&detail), async {
729                bitbake::run_with_reporter(
730                    action,
731                    &config.dist_root,
732                    recipe_spec.as_ref(),
733                    reporter,
734                )
735                .await?;
736                Ok(())
737            })
738            .await?;
739    }
740
741    Ok(config.dist_root)
742}
743
744/// Builds the install manifest for a generated `.bb` recipe from the pipeline's
745/// resolved outputs. Artifact fields are the bare file names the `dist/` stage
746/// writes under each subdirectory.
747#[allow(clippy::too_many_arguments)]
748fn recipe_spec(
749    recipe_name: &str,
750    deploy_root: &str,
751    application: &Path,
752    firmware_outputs: &HashMap<VisibleCore, PathBuf>,
753    ta: Option<&Path>,
754    hmi_dist: Option<&Path>,
755    systemd_unit: &str,
756    dts_files: &[(String, String)],
757    boot_files: &[(String, String)],
758) -> Result<RecipeSpec, PipelineError> {
759    let file_name = |path: &Path| -> Result<String, PipelineError> {
760        path.file_name()
761            .and_then(|n| n.to_str())
762            .map(str::to_string)
763            .ok_or_else(|| PipelineError::Codegen(format!("no file name for {}", path.display())))
764    };
765
766    // Firmware is staged as `firmware/<core>.elf`, matching the dist layout.
767    let firmware = firmware_outputs
768        .keys()
769        .map(|core| format!("{core}.elf"))
770        .collect();
771
772    Ok(RecipeSpec {
773        recipe_name: recipe_name.to_string(),
774        deploy_root: deploy_root.to_string(),
775        app_bin: file_name(application)?,
776        firmware,
777        ta: ta.map(file_name).transpose()?,
778        has_www: hmi_dist.is_some(),
779        systemd_unit: systemd_unit.to_string(),
780        dts_files: dts_files.iter().map(|(name, _)| name.clone()).collect(),
781        boot_files: boot_files.iter().map(|(name, _)| name.clone()).collect(),
782    })
783}
784
785struct StepRunner<'a> {
786    reporter: Option<&'a dyn BuildReporter>,
787    next: usize,
788    total: usize,
789}
790
791impl<'a> StepRunner<'a> {
792    fn new(reporter: Option<&'a dyn BuildReporter>, total: usize) -> Self {
793        Self {
794            reporter,
795            next: 0,
796            total,
797        }
798    }
799
800    async fn run<T, F>(
801        &mut self,
802        name: &str,
803        detail: Option<&str>,
804        future: F,
805    ) -> Result<T, PipelineError>
806    where
807        F: std::future::Future<Output = Result<T, PipelineError>>,
808    {
809        self.next += 1;
810        let step = BuildStep {
811            index: self.next,
812            total: self.total,
813            name,
814            detail,
815        };
816        if let Some(reporter) = self.reporter {
817            reporter.step_started(step);
818        }
819
820        let result = future.await;
821        if let Some(reporter) = self.reporter {
822            match &result {
823                Ok(_) => reporter.step_succeeded(step),
824                Err(_) => reporter.step_failed(step),
825            }
826        }
827        result
828    }
829}
830
831/// The [`VisibleCore`] a chip-database coprocessor entry names.
832pub(crate) fn visible_core_of(cp: &Coprocessor) -> Result<VisibleCore, PipelineError> {
833    cp.name
834        .parse()
835        .map_err(|_| PipelineError::UnknownCore(cp.name.clone()))
836}
837
838/// The Rust target triple for a coprocessor core (the single source of truth).
839pub(crate) fn core_rust_triple(core: VisibleCore) -> String {
840    match core {
841        VisibleCore::Mcore(name) => name.rust_triple(),
842        VisibleCore::Rcore(name) => name.rust_triple(),
843        VisibleCore::Rvcore(name) => name.rust_triple(),
844        // The AP is never a firmware target (it is absent from the chip `cp`
845        // list); the application stage resolves its Linux triple separately.
846        VisibleCore::Ap => "aarch64-unknown-linux-gnu".to_string(),
847    }
848}
849
850/// Resolves the shared message-type crate's Rust path.
851///
852/// Prefers the `[package] name` in the shared crate's `Cargo.toml`; falls back to
853/// the last component of `[profile] shared` when that file cannot be read (so the
854/// dry-run [`plan`] works without every crate present on disk).
855async fn resolve_ty_crate(
856    session: &Session,
857    manifest_dir: &Path,
858) -> Result<syn::Path, PipelineError> {
859    let shared_dir = manifest_dir.join(&session.config.profile.shared);
860    let name = match crate::compile::utils::binary_name(&shared_dir).await {
861        Ok(name) => name,
862        Err(_) => shared_dir
863            .file_name()
864            .and_then(|n| n.to_str())
865            .unwrap_or("shared")
866            .to_string(),
867    };
868    let ident = name.replace('-', "_");
869    syn::parse_str(&format!("::{ident}"))
870        .map_err(|e| PipelineError::Codegen(format!("bad shared crate name '{ident}': {e}")))
871}
872
873/// Builds the systemd unit for the application, using its binary name for
874/// `ExecStart=`/`WorkingDirectory=` under the manifest's deploy root.
875async fn systemd_config(
876    session: &Session,
877    app_path: &Path,
878) -> Result<SystemdUnitConfig, PipelineError> {
879    let binary = crate::compile::utils::binary_name(app_path)
880        .await
881        .map_err(PipelineError::Codegen)?;
882    let deploy_root = &session.config.profile.deploy_root;
883    Ok(SystemdUnitConfig {
884        unit_name: "consortium-app".to_string(),
885        exec_start: format!("{deploy_root}/app/{binary}"),
886        working_directory: format!("{deploy_root}/app"),
887        description: None,
888        restart: SystemdRestartPolicy::OnFailure,
889    })
890}
891
892/// Writes a generated init module into a crate at `src/consortium.gen.rs`.
893///
894/// The crate is expected to bring it in with `include!("consortium.gen.rs")` (or
895/// a `mod`), which is how the melt-pot examples wire generated code in.
896async fn write_generated_module(
897    project_dir: &Path,
898    source: &str,
899) -> Result<PathBuf, PipelineError> {
900    let src_dir = project_dir.join("src");
901    tokio::fs::create_dir_all(&src_dir)
902        .await
903        .map_err(|source| {
904            consortium_log::trace!("failed to create src dir {}", src_dir.display());
905            PipelineError::GeneratedWrite {
906                path: src_dir.display().to_string(),
907                source,
908            }
909        })?;
910    let path = src_dir.join(GENERATED_MODULE);
911    consortium_log::trace!(
912        "writing {} bytes of generated code to {}",
913        source.len(),
914        path.display()
915    );
916    tokio::fs::write(&path, source).await.map_err(|source| {
917        consortium_log::trace!("failed to write generated module {}", path.display());
918        PipelineError::GeneratedWrite {
919            path: path.display().to_string(),
920            source,
921        }
922    })?;
923    Ok(path)
924}
925
926/// Writes the structured remoteproc linker memory map into the firmware crate.
927async fn write_generated_memory_x(
928    config: &crate::compile::firmware::FirmwareConfiguration,
929) -> Result<PathBuf, PipelineError> {
930    let layout = config.remoteproc.as_ref().ok_or_else(|| {
931        PipelineError::Codegen(format!(
932            "firmware endpoint {} has no remoteproc memory contract",
933            config.path.display()
934        ))
935    })?;
936    let source = layout
937        .linker_memory()
938        .map_err(|error| PipelineError::Codegen(error.to_string()))?
939        .render();
940    let path = config.path.join(GENERATED_MEMORY_X);
941    tokio::fs::write(&path, source)
942        .await
943        .map_err(|source| PipelineError::GeneratedWrite {
944            path: path.display().to_string(),
945            source,
946        })?;
947    Ok(path)
948}
949
950#[cfg(test)]
951mod tests {
952    use super::*;
953
954    const STM32: &str = include_str!("../../../consortium-cfg/Consortium.example.stm32mp257.toml");
955
956    /// Fabricates a project tree on disk (manifest + per-endpoint crates with
957    /// `Cargo.toml`s, no real sources) so the dry-run [`plan`] can resolve crate
958    /// names and expected ELF paths without invoking any build.
959    fn fabricate_project(tag: &str, manifest: &str) -> PathBuf {
960        let root =
961            std::env::temp_dir().join(format!("consortium-pipeline-{tag}-{}", std::process::id()));
962        let _ = std::fs::remove_dir_all(&root);
963        std::fs::create_dir_all(&root).unwrap();
964        std::fs::write(root.join("Consortium.toml"), manifest).unwrap();
965        for (dir, pkg) in [
966            ("app", "melt-pot-app"),
967            ("ta", "melt-pot-ta"),
968            ("firmware", "melt-pot-mcu"),
969            ("shared", "melt-pot-shared"),
970        ] {
971            let crate_dir = root.join(dir);
972            std::fs::create_dir_all(&crate_dir).unwrap();
973            std::fs::write(
974                crate_dir.join("Cargo.toml"),
975                format!("[package]\nname = \"{pkg}\"\nversion = \"0.0.0\"\n"),
976            )
977            .unwrap();
978        }
979        root
980    }
981
982    fn unified(manifest: &str) -> String {
983        manifest.replace(
984            "deploy_root = \"/opt/consortium\"",
985            "deploy_root = \"/opt/consortium\"\nworkspace   = \"unified\"",
986        )
987    }
988
989    #[test]
990    fn unified_workspace_requires_a_root_workspace_manifest() {
991        let manifest = unified(STM32);
992        let root = fabricate_project("unified-missing", &manifest);
993        let session = Session::from_toml(&manifest).unwrap();
994
995        let err = resolve_workspace(&session, &root).expect_err("no root Cargo.toml");
996        assert!(matches!(
997            err,
998            PipelineError::UnifiedWorkspace(_, "does not exist")
999        ));
1000
1001        // A plain package manifest is not a workspace root.
1002        std::fs::write(
1003            root.join("Cargo.toml"),
1004            "[package]\nname = \"x\"\nversion = \"0.0.0\"\n",
1005        )
1006        .unwrap();
1007        let err = resolve_workspace(&session, &root).expect_err("no [workspace]");
1008        assert!(matches!(
1009            err,
1010            PipelineError::UnifiedWorkspace(_, "has no [workspace] table")
1011        ));
1012
1013        let _ = std::fs::remove_dir_all(&root);
1014    }
1015
1016    #[tokio::test]
1017    async fn unified_workspace_builds_into_the_manifest_target() {
1018        let manifest = unified(STM32);
1019        let root = fabricate_project("unified", &manifest);
1020        std::fs::write(
1021            root.join("Cargo.toml"),
1022            "[workspace]\nmembers = [\"app\", \"firmware\"]\n",
1023        )
1024        .unwrap();
1025        let session = Session::from_toml(&manifest).unwrap();
1026
1027        let workspace = resolve_workspace(&session, &root).expect("workspace root present");
1028        assert_eq!(workspace.layout, Workspace::Unified);
1029        let ty_crate: syn::Path = syn::parse_str("::shared").unwrap();
1030        let firmware = firmware::plan_firmware_builds(
1031            &session,
1032            &workspace,
1033            CargoOptions::default(),
1034            &ty_crate,
1035        )
1036        .unwrap();
1037        let elf = firmware::firmware_elf_path(&firmware[0].config)
1038            .await
1039            .unwrap();
1040        assert_eq!(
1041            elf,
1042            root.join("target/thumbv8m.main-none-eabihf/debug/melt-pot-mcu")
1043        );
1044
1045        let dbg_elfs = HashMap::from([(
1046            firmware[0].core,
1047            crate::compile::firmware::original_elf_path(&elf),
1048        )]);
1049        let application = application::plan_application_build(
1050            &session,
1051            &workspace,
1052            None,
1053            &dbg_elfs,
1054            CargoOptions::default(),
1055            &ty_crate,
1056        )
1057        .unwrap();
1058        assert!(
1059            application
1060                .generated
1061                .contains("::core::env!(\"CSTI_MANIFEST_PATH\")")
1062        );
1063        assert!(
1064            application
1065                .generated
1066                .contains("\"/target/thumbv8m.main-none-eabihf/debug/melt-pot-mcu_original.elf\"")
1067        );
1068
1069        let _ = std::fs::remove_dir_all(&root);
1070    }
1071
1072    #[tokio::test]
1073    async fn plan_threads_all_stages() {
1074        let manifest = format!("{STM32}\n[dts]\nbase = \"test-board\"\nmode = \"merge\"\n");
1075        let root = fabricate_project("plan", &manifest);
1076        let vendor = root.join("linux/arch/arm64/boot/dts/st");
1077        std::fs::create_dir_all(&vendor).unwrap();
1078        std::fs::write(
1079            vendor.join("test-board.dts"),
1080            r#"/dts-v1/;
1081            / {
1082                model = "Pipeline test board";
1083                m33: remoteproc-m33 {
1084                    compatible = "st,stm32mp2-m33";
1085                };
1086            };"#,
1087        )
1088        .unwrap();
1089        let session = Session::from_toml(&manifest).unwrap();
1090        let config = PipelineConfig {
1091            manifest_path: root.join("Consortium.toml"),
1092            dist_root: root.join("dist"),
1093            linux: root.join("linux"),
1094            release: true,
1095            offline: false,
1096            hmi: Some(HmiConfiguration {
1097                engine: HmiEngine::Cog,
1098                app: None,
1099                project_dir: root.join("website"),
1100                dist_dir: root.join("website/dist"),
1101                command: crate::compile::web::HmiBuildCommand::Custom(vec!["true".into()]),
1102            }),
1103            tee_toolchain: Some(TeeToolchain {
1104                ta_dev_kit_dir: PathBuf::from("/opt/optee/export-ta_arm64"),
1105                optee_signer: PathBuf::from("/opt/optee/scripts/sign_encrypt.py"),
1106                linker: None,
1107            }),
1108            sign_tee: true,
1109            strict_remoteproc: true,
1110            bitbake: Vec::new(),
1111        };
1112
1113        let plan = plan(&session, &config).await.expect("plan succeeds");
1114
1115        // ty_crate came from the shared crate's Cargo.toml package name
1116        // (`melt-pot-shared` → `::melt_pot_shared`).
1117        assert_eq!(
1118            plan.ty_crate.segments.last().unwrap().ident.to_string(),
1119            "melt_pot_shared"
1120        );
1121
1122        // Stage 1: exactly cm33 (the remote firmware target) is planned, and its
1123        // expected ELF path uses the resolved binary name + target triple.
1124        assert_eq!(plan.firmware.len(), 1);
1125        let cm33 = VisibleCore::Mcore("cm33".parse().unwrap());
1126        // The endpoint is `./firmware`, so the resolved path keeps that prefix.
1127        let expected_elf =
1128            root.join("./firmware/target/thumbv8m.main-none-eabihf/release/melt-pot-mcu");
1129        assert_eq!(plan.firmware_outputs[&cm33], expected_elf);
1130
1131        // Stage 2: the dbg ELF map points cm33's ring at the `*_original.elf`
1132        // sibling (the defmt-bearing image), absolutized for `include_bytes!`.
1133        let original_elf =
1134            std::path::absolute(crate::compile::firmware::original_elf_path(&expected_elf))
1135                .unwrap();
1136        assert_eq!(plan.dbg_elfs[&cm33], original_elf);
1137
1138        // Stage 5: the AP module embeds the `*_original.elf` Stage 1 will produce,
1139        // and the app targets Linux with the HMI served from the deploy root.
1140        // (prettyplease may wrap a long `include_bytes!(...)` across lines, so
1141        // assert on the embedded path literal rather than the whole call.)
1142        // The path is relative to the lone firmware crate, which the application
1143        // build receives as `CSTI_MCU_PATH`.
1144        assert!(plan.application.generated.contains("include_bytes!"));
1145        assert!(
1146            plan.application
1147                .generated
1148                .contains("::core::env!(\"CSTI_MCU_PATH\")")
1149        );
1150        let relative = original_elf.strip_prefix(root.join("firmware")).unwrap();
1151        assert!(
1152            plan.application
1153                .generated
1154                .contains(&format!("\"/{}\"", relative.display()))
1155        );
1156        let workspace = plan.application.config.workspace.as_ref().unwrap();
1157        assert_eq!(
1158            workspace.mcu_dir.as_deref(),
1159            Some(root.join("firmware").as_path())
1160        );
1161        assert_eq!(plan.application.config.target, "aarch64-unknown-linux-gnu");
1162        assert_eq!(
1163            plan.application.config.hmi.as_ref().unwrap(),
1164            &crate::compile::env::HmiConfig::Cog {
1165                dist: "/opt/consortium/www".to_string(),
1166                debug_url: Some("http://127.0.0.1:5173/".to_string()),
1167            }
1168        );
1169
1170        // Stage 4: TA resolved from the manifest.
1171        assert!(plan.ta.is_some());
1172
1173        // Stage 6: systemd ExecStart uses the app binary under the deploy root.
1174        assert_eq!(plan.systemd.exec_start, "/opt/consortium/app/melt-pot-app");
1175
1176        // Stage 6: the UIO manifest yields a merged device tree from the
1177        // configured kernel fixture plus the kernel config fragment enabling
1178        // its drivers.
1179        assert_eq!(
1180            plan.dts_files
1181                .iter()
1182                .map(|(n, _)| n.as_str())
1183                .collect::<Vec<_>>(),
1184            vec!["consortium-test-board.dts"]
1185        );
1186        // A merged `.dts` re-emits the vendor base plus the injected UIO nodes.
1187        assert!(plan.dts_files[0].1.contains("compatible = \"generic-uio\""));
1188        let kconfig = plan
1189            .kernel_files
1190            .iter()
1191            .find(|(n, _)| n == dts::KCONFIG_NAME)
1192            .expect("consolidated kconfig fragment planned");
1193        assert!(kconfig.1.contains("CONFIG_UIO_PDRV_GENIRQ=y"));
1194        assert!(kconfig.1.contains("CONFIG_UIO_DMEM_GENIRQ=y"));
1195        assert!(kconfig.1.contains("CONFIG_REMOTEPROC=y"));
1196        assert!(kconfig.1.contains("CONFIG_STM32_RPROC=y"));
1197        assert_eq!(
1198            plan.kernel_files
1199                .iter()
1200                .filter(|(name, _)| name.ends_with(".cfg"))
1201                .count(),
1202            1
1203        );
1204        assert!(kconfig.1.contains("CONFIG_OF_OVERLAY=y"));
1205        assert!(kconfig.1.contains("CONFIG_OF_CONFIGFS=y"));
1206        // STM32 → an extlinux boot-args fragment lands under `dist/boot/`.
1207        assert!(plan.boot_files.iter().any(|(n, _)| n == "extlinux-uio.cfg"));
1208        // No `[profile] kernel_recipe` in the example, so no bbappend is emitted.
1209        assert!(
1210            !plan
1211                .kernel_files
1212                .iter()
1213                .any(|(n, _)| n.ends_with(".bbappend"))
1214        );
1215
1216        let _ = std::fs::remove_dir_all(&root);
1217    }
1218}