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