Skip to main content

cuttlefish_host/
pipeline.rs

1//! Checking that a pipeline's blocks fit together, before any of them run.
2//!
3//! A pipeline feeds each block's output into the next one's input. Those seams
4//! are where composition actually goes wrong: a block producing a summary string
5//! handed to one expecting a list of chunks does not fail at the seam — it fails
6//! somewhere inside the second block, as a confusing error about a field that is
7//! missing, or worse, as a plausible answer computed from nothing.
8//!
9//! So the seams are checked first, using signatures the blocks declare
10//! themselves (see [`crate::runner::read_signature`]). A mismatch names both
11//! blocks and both types and stops the job before it starts.
12//!
13//! # What this deliberately is not
14//!
15//! Not type *inference*. Every block states its own signature; nothing is
16//! derived. Inference earns its keep in a language with expressions, where types
17//! flow through terms nobody wants to annotate. A pipeline is a list of already
18//! typed things, so there is nothing to infer and machinery to infer it would be
19//! cost without benefit.
20//!
21//! Not a DAG, yet. A pipeline is linear. Branching and joining are real needs,
22//! but a linear chain covers the pipelines that exist today, and the type
23//! discipline established here is what a DAG would extend rather than replace.
24
25use crate::catalog::{Catalog, ResolutionContext, Resolved};
26use cuttlefish_abi::{Signature, Ty};
27use std::path::{Path, PathBuf};
28use wasmtime::Engine;
29
30/// One stage of a checked pipeline.
31pub struct Stage {
32    /// Display name — a file stem for a path-resolved stage, or the bare
33    /// catalog name (no `@version`) for a cataloged one. Used only for
34    /// output and the bundle manifest, never round-tripped into a lookup.
35    pub name: String,
36    /// Block or bundle.
37    pub kind: crate::catalog::ArtifactKind,
38    /// The exact `name@version` this stage resolved to, if it came from the
39    /// catalog. `None` for a direct path.
40    pub resolved: Option<String>,
41    /// The compiled module (block) or `.cfbundle` (bundle) bytes.
42    pub module_bytes: Vec<u8>,
43    /// What it declared.
44    pub signature: Signature,
45}
46
47/// One pipeline entry's bytes, already resolved and loaded — from disk or
48/// from the catalog's blob store. `check()`'s input; see resolve_and_load
49/// (added in a later change) for how one of these gets built.
50pub struct ResolvedInput {
51    /// Display name, same convention as [`Stage::name`].
52    pub name: String,
53    /// Block or bundle, already determined (either sniffed from a direct
54    /// path's magic bytes, or read from the catalog entry's own `kind`).
55    pub kind: crate::catalog::ArtifactKind,
56    /// The exact `name@version`, if this came from the catalog.
57    pub resolved: Option<String>,
58    /// The raw bytes.
59    pub bytes: Vec<u8>,
60}
61
62impl std::fmt::Debug for ResolvedInput {
63    /// Hand-written so a `Debug`-formatted `ResolvedInput` (e.g. from a
64    /// panic or failed assertion) never dumps a multi-megabyte wasm module
65    /// as a wall of numbers — `bytes` is reported by length only.
66    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
67        f.debug_struct("ResolvedInput")
68            .field("name", &self.name)
69            .field("kind", &self.kind)
70            .field("resolved", &self.resolved)
71            .field("bytes", &format!("<{} bytes>", self.bytes.len()))
72            .finish()
73    }
74}
75
76/// A pipeline whose seams have been checked.
77///
78/// Only constructible through [`check`], so holding one is evidence the check
79/// ran — a type that cannot be built in an unchecked state is worth more than a
80/// convention that it should not be.
81pub struct Checked {
82    stages: Vec<Stage>,
83}
84
85impl Checked {
86    /// The stages, in execution order.
87    pub fn stages(&self) -> &[Stage] {
88        &self.stages
89    }
90
91    /// What the pipeline as a whole accepts — its first block's input.
92    pub fn input(&self) -> &Ty {
93        &self.stages[0].signature.input
94    }
95
96    /// What it produces — its last block's output.
97    pub fn output(&self) -> &Ty {
98        &self.stages[self.stages.len() - 1].signature.output
99    }
100}
101
102/// Why a pipeline was rejected.
103#[derive(Debug, thiserror::Error)]
104pub enum PipelineError {
105    /// A block could not be read from disk.
106    #[error("reading block {path}: {source}")]
107    Unreadable {
108        /// The block that could not be read.
109        path: PathBuf,
110        /// The underlying I/O failure.
111        source: std::io::Error,
112    },
113    /// A stage could not be inspected: a directory instead of an artifact,
114    /// unrecognized magic bytes, a wasm module that failed to load, or a
115    /// bundle whose cached signature string doesn't parse.
116    #[error("inspecting {name}: {message}")]
117    Uninspectable {
118        /// The stage's display name.
119        name: String,
120        /// What went wrong.
121        message: String,
122    },
123    /// Resolving a pipeline entry through the catalog failed.
124    #[error(transparent)]
125    Resolution(#[from] crate::catalog::CatalogError),
126    /// Two adjacent blocks do not fit.
127    #[error(
128        "block {consumer} expects {expected}, but {producer} before it produces {produced}.\n\
129         Adjust one of the two signatures, or insert a block that converts between them."
130    )]
131    SeamMismatch {
132        /// The block producing the value.
133        producer: String,
134        /// What it produces.
135        produced: String,
136        /// The block receiving it.
137        consumer: String,
138        /// What it needs.
139        expected: String,
140    },
141    /// The pipeline had no blocks.
142    #[error("a pipeline needs at least one block")]
143    Empty,
144}
145
146/// Check that a resolved pipeline's seams fit.
147///
148/// Fails on the *first* mismatch rather than collecting all of them. A later
149/// seam's types depend on an earlier one being what it claimed, so reporting
150/// downstream mismatches after an upstream failure would mostly report
151/// consequences of the first error rather than independent problems.
152///
153/// Takes already-resolved, already-loaded stages — see resolve_and_load
154/// (added in a later change) for turning a spec's pipeline entries into
155/// these. `check` itself does no disk or catalog I/O; a `Direct` vs.
156/// `Cataloged` entry looks identical to it once loaded.
157pub fn check(engine: &Engine, inputs: &[ResolvedInput]) -> Result<Checked, PipelineError> {
158    if inputs.is_empty() {
159        return Err(PipelineError::Empty);
160    }
161
162    let mut stages: Vec<Stage> = Vec::with_capacity(inputs.len());
163    for input in inputs {
164        let signature = read_stage_signature(engine, input)?;
165
166        if let Some(previous) = stages.last() {
167            if !previous.signature.output.assignable_to(&signature.input) {
168                return Err(PipelineError::SeamMismatch {
169                    producer: previous.name.clone(),
170                    produced: previous.signature.output.to_string(),
171                    consumer: input.name.clone(),
172                    expected: signature.input.to_string(),
173                });
174            }
175        }
176
177        stages.push(Stage {
178            name: input.name.clone(),
179            kind: input.kind,
180            resolved: input.resolved.clone(),
181            module_bytes: input.bytes.clone(),
182            signature,
183        });
184    }
185
186    Ok(Checked { stages })
187}
188
189/// Read one resolved input's declared [`Signature`], regardless of whether
190/// it's a block or a bundle. Shared by [`check`] (linear) and the graph
191/// checker in `crate::dag` — both need exactly this per-node lookup, just
192/// composed differently around it.
193pub fn read_stage_signature(
194    engine: &Engine,
195    input: &ResolvedInput,
196) -> Result<Signature, PipelineError> {
197    match input.kind {
198        crate::catalog::ArtifactKind::Block => crate::runner::read_signature(engine, &input.bytes)
199            .map_err(|e| PipelineError::Uninspectable {
200                name: input.name.clone(),
201                message: format!("{e:#}"),
202            }),
203        crate::catalog::ArtifactKind::Bundle => {
204            let compact = crate::catalog::read_bundle_signature(&input.bytes, &input.name)
205                .map_err(|e| PipelineError::Uninspectable {
206                    name: input.name.clone(),
207                    // `read_bundle_signature`'s own error already embeds
208                    // the name (it's `{path}: {reason}` with `path` set
209                    // to our `name`); re-stringifying the whole error
210                    // here would print the name twice. Pull out just the
211                    // reason so `Uninspectable`'s own `{name}: {message}`
212                    // formatting is the only place the name appears.
213                    message: match e {
214                        crate::catalog::CatalogError::UninspectableArtifact { reason, .. } => {
215                            reason
216                        }
217                        other => other.to_string(),
218                    },
219                })?;
220            compact
221                .parse::<Signature>()
222                .map_err(|e| PipelineError::Uninspectable {
223                    name: input.name.clone(),
224                    message: format!("cached signature `{compact}` does not parse: {e}"),
225                })
226        }
227    }
228}
229
230/// Turn one pipeline-entry string from a spec into a loaded, kind-tagged
231/// [`ResolvedInput`], ready for [`check`]. Shared by `cuttlefishd`'s run
232/// path and `cuttlefish build` — the only two callers, and both need the
233/// same resolve-then-load behavior.
234///
235/// An entry that resolves to a real path once joined against `spec_dir` is
236/// treated as that path — matching the join every other spec-relative
237/// reference (`capabilities`) already gets, so a relative block path means
238/// the same thing regardless of the process's working directory. This
239/// mirrors [`Catalog::resolve`]'s own Direct-vs-Cataloged decision (`s.ends_with(".wasm")
240/// || Path::new(s).exists()`), just relative to `spec_dir` instead of the
241/// process's CWD, rather than guessing from the raw string's shape (`entry`
242/// containing `/`) before `resolve` ever gets a look — a catalog name is
243/// free to contain `/` (a namespaced convention like `"team/cat-a@1"`), and
244/// joining on shape alone would corrupt such a name into a bogus path before
245/// the catalog's own index lookup ever sees it.
246///
247/// A `.wasm`/`.cfbundle` suffix is still always treated as a path reference
248/// even when the joined candidate doesn't exist on disk, so a genuinely
249/// missing compiled artifact still names the expected file in its error
250/// instead of silently falling back to a catalog lookup on a string that
251/// happens to contain that suffix. Anything else that doesn't resolve to a
252/// real file is passed to [`Catalog::resolve`] unmodified — joining it would
253/// turn `name@version` into `<spec_dir>/name@version`, which matches neither
254/// an index key nor a real file.
255pub fn resolve_and_load(
256    catalog: &Catalog,
257    spec_dir: &Path,
258    entry: &str,
259    context: ResolutionContext,
260) -> Result<ResolvedInput, PipelineError> {
261    let candidate = spec_dir.join(entry);
262    let use_joined = candidate.exists() || entry.ends_with(".wasm") || entry.ends_with(".cfbundle");
263    let joined;
264    let s: &str = if use_joined {
265        joined = candidate.to_string_lossy().into_owned();
266        &joined
267    } else {
268        entry
269    };
270
271    match catalog.resolve(s, context)? {
272        Resolved::Direct(path) => {
273            if path.is_dir() {
274                return Err(PipelineError::Uninspectable {
275                    name: path.display().to_string(),
276                    message: "this is a directory. A pipeline names compiled \
277                              `.wasm`/`.cfbundle` artifacts, not block source \
278                              directories — build the block first and point at \
279                              the compiled artifact."
280                        .into(),
281                });
282            }
283            let bytes = std::fs::read(&path).map_err(|source| PipelineError::Unreadable {
284                path: path.clone(),
285                source,
286            })?;
287            let kind = crate::catalog::sniff_artifact_kind(&bytes).ok_or_else(|| {
288                PipelineError::Uninspectable {
289                    name: path.display().to_string(),
290                    message: "not a recognized artifact (neither wasm nor .cfbundle magic \
291                              bytes)"
292                        .into(),
293                }
294            })?;
295            Ok(ResolvedInput {
296                name: name_of(&path),
297                kind,
298                resolved: None,
299                bytes,
300            })
301        }
302        Resolved::Cataloged {
303            name_version,
304            entry,
305        } => {
306            let bytes = catalog.read_blob(&entry)?;
307            let name = name_version
308                .split_once('@')
309                .map(|(n, _)| n)
310                .unwrap_or(&name_version)
311                .to_string();
312            Ok(ResolvedInput {
313                name,
314                kind: entry.kind,
315                resolved: Some(name_version),
316                bytes,
317            })
318        }
319    }
320}
321
322/// A short name for error messages and manifest output — the file stem, or
323/// the whole path when there isn't one (`Path::file_stem(".wasm")` returns
324/// `Some(".wasm")` under the leading-dot rule, so this never panics or
325/// empties out on a no-real-basename path).
326fn name_of(path: &Path) -> String {
327    path.file_stem()
328        .map(|s| s.to_string_lossy().into_owned())
329        .unwrap_or_else(|| path.display().to_string())
330}