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}