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 /// The script's own source text, for a `Script`-kind stage — `None` for
46 /// `Block`/`Bundle`. Threaded straight from `ResolvedInput::script`.
47 pub script: Option<String>,
48}
49
50/// One pipeline entry's bytes, already resolved and loaded — from disk or
51/// from the catalog's blob store. `check()`'s input; see resolve_and_load
52/// (added in a later change) for how one of these gets built.
53pub struct ResolvedInput {
54 /// Display name, same convention as [`Stage::name`].
55 pub name: String,
56 /// Block or bundle, already determined (either sniffed from a direct
57 /// path's magic bytes, or read from the catalog entry's own `kind`).
58 pub kind: crate::catalog::ArtifactKind,
59 /// The exact `name@version`, if this came from the catalog.
60 pub resolved: Option<String>,
61 /// The raw bytes.
62 pub bytes: Vec<u8>,
63 /// The script's own source text, for a `Script`-kind entry — `None` for
64 /// `Block`/`Bundle`. `bytes` for a `Script` entry is the *interpreter's*
65 /// bytes (see `resolve_and_load`), not the script; this field is where
66 /// the actual script text lives through the rest of the pipeline.
67 pub script: Option<String>,
68}
69
70impl std::fmt::Debug for ResolvedInput {
71 /// Hand-written so a `Debug`-formatted `ResolvedInput` (e.g. from a
72 /// panic or failed assertion) never dumps a multi-megabyte wasm module
73 /// as a wall of numbers — `bytes` is reported by length only.
74 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
75 f.debug_struct("ResolvedInput")
76 .field("name", &self.name)
77 .field("kind", &self.kind)
78 .field("resolved", &self.resolved)
79 .field("bytes", &format!("<{} bytes>", self.bytes.len()))
80 .field("script", &self.script)
81 .finish()
82 }
83}
84
85/// A pipeline whose seams have been checked.
86///
87/// Only constructible through [`check`], so holding one is evidence the check
88/// ran — a type that cannot be built in an unchecked state is worth more than a
89/// convention that it should not be.
90pub struct Checked {
91 stages: Vec<Stage>,
92}
93
94impl Checked {
95 /// The stages, in execution order.
96 pub fn stages(&self) -> &[Stage] {
97 &self.stages
98 }
99
100 /// What the pipeline as a whole accepts — its first block's input.
101 pub fn input(&self) -> &Ty {
102 &self.stages[0].signature.input
103 }
104
105 /// What it produces — its last block's output.
106 pub fn output(&self) -> &Ty {
107 &self.stages[self.stages.len() - 1].signature.output
108 }
109}
110
111/// Why a pipeline was rejected.
112#[derive(Debug, thiserror::Error)]
113pub enum PipelineError {
114 /// A block could not be read from disk.
115 #[error("reading block {path}: {source}")]
116 Unreadable {
117 /// The block that could not be read.
118 path: PathBuf,
119 /// The underlying I/O failure.
120 source: std::io::Error,
121 },
122 /// A stage could not be inspected: a directory instead of an artifact,
123 /// unrecognized magic bytes, a wasm module that failed to load, or a
124 /// bundle whose cached signature string doesn't parse.
125 #[error("inspecting {name}: {message}")]
126 Uninspectable {
127 /// The stage's display name.
128 name: String,
129 /// What went wrong.
130 message: String,
131 },
132 /// Resolving a pipeline entry through the catalog failed.
133 #[error(transparent)]
134 Resolution(#[from] crate::catalog::CatalogError),
135 /// Two adjacent blocks do not fit.
136 #[error(
137 "block {consumer} expects {expected}, but {producer} before it produces {produced}.\n\
138 Adjust one of the two signatures, or insert a block that converts between them."
139 )]
140 SeamMismatch {
141 /// The block producing the value.
142 producer: String,
143 /// What it produces.
144 produced: String,
145 /// The block receiving it.
146 consumer: String,
147 /// What it needs.
148 expected: String,
149 },
150 /// The pipeline had no blocks.
151 #[error("a pipeline needs at least one block")]
152 Empty,
153}
154
155/// Check that a resolved pipeline's seams fit.
156///
157/// Fails on the *first* mismatch rather than collecting all of them. A later
158/// seam's types depend on an earlier one being what it claimed, so reporting
159/// downstream mismatches after an upstream failure would mostly report
160/// consequences of the first error rather than independent problems.
161///
162/// Takes already-resolved, already-loaded stages — see resolve_and_load
163/// (added in a later change) for turning a spec's pipeline entries into
164/// these. `check` itself does no disk or catalog I/O; a `Direct` vs.
165/// `Cataloged` entry looks identical to it once loaded.
166pub fn check(engine: &Engine, inputs: &[ResolvedInput]) -> Result<Checked, PipelineError> {
167 if inputs.is_empty() {
168 return Err(PipelineError::Empty);
169 }
170
171 let mut stages: Vec<Stage> = Vec::with_capacity(inputs.len());
172 for input in inputs {
173 let signature = read_stage_signature(engine, input)?;
174
175 if let Some(previous) = stages.last() {
176 if !previous.signature.output.assignable_to(&signature.input) {
177 return Err(PipelineError::SeamMismatch {
178 producer: previous.name.clone(),
179 produced: previous.signature.output.to_string(),
180 consumer: input.name.clone(),
181 expected: signature.input.to_string(),
182 });
183 }
184 }
185
186 stages.push(Stage {
187 name: input.name.clone(),
188 kind: input.kind,
189 resolved: input.resolved.clone(),
190 module_bytes: input.bytes.clone(),
191 signature,
192 script: input.script.clone(),
193 });
194 }
195
196 Ok(Checked { stages })
197}
198
199/// Read one resolved input's declared [`Signature`], regardless of whether
200/// it's a block or a bundle. Shared by [`check`] (linear) and the graph
201/// checker in `crate::dag` — both need exactly this per-node lookup, just
202/// composed differently around it.
203pub fn read_stage_signature(
204 engine: &Engine,
205 input: &ResolvedInput,
206) -> Result<Signature, PipelineError> {
207 match input.kind {
208 crate::catalog::ArtifactKind::Block => crate::runner::read_signature(engine, &input.bytes)
209 .map_err(|e| PipelineError::Uninspectable {
210 name: input.name.clone(),
211 message: format!("{e:#}"),
212 }),
213 crate::catalog::ArtifactKind::Bundle => {
214 let compact = crate::catalog::read_bundle_signature(&input.bytes, &input.name)
215 .map_err(|e| PipelineError::Uninspectable {
216 name: input.name.clone(),
217 // `read_bundle_signature`'s own error already embeds
218 // the name (it's `{path}: {reason}` with `path` set
219 // to our `name`); re-stringifying the whole error
220 // here would print the name twice. Pull out just the
221 // reason so `Uninspectable`'s own `{name}: {message}`
222 // formatting is the only place the name appears.
223 message: match e {
224 crate::catalog::CatalogError::UninspectableArtifact { reason, .. } => {
225 reason
226 }
227 other => other.to_string(),
228 },
229 })?;
230 compact
231 .parse::<Signature>()
232 .map_err(|e| PipelineError::Uninspectable {
233 name: input.name.clone(),
234 message: format!("cached signature `{compact}` does not parse: {e}"),
235 })
236 }
237 crate::catalog::ArtifactKind::Script => {
238 let script = input
239 .script
240 .as_deref()
241 .ok_or_else(|| PipelineError::Uninspectable {
242 name: input.name.clone(),
243 message: "a Script-kind ResolvedInput with no script text — this is an \
244 internal bug in resolve_and_load, not a user-facing error"
245 .to_string(),
246 })?;
247 let compact = crate::catalog::read_script_signature(script.as_bytes(), &input.name)
248 .map_err(|e| PipelineError::Uninspectable {
249 name: input.name.clone(),
250 message: match e {
251 crate::catalog::CatalogError::UninspectableArtifact { reason, .. } => {
252 reason
253 }
254 other => other.to_string(),
255 },
256 })?;
257 compact
258 .parse::<Signature>()
259 .map_err(|e| PipelineError::Uninspectable {
260 name: input.name.clone(),
261 message: format!("cached signature `{compact}` does not parse: {e}"),
262 })
263 }
264 }
265}
266
267/// Turn one pipeline-entry string from a spec into a loaded, kind-tagged
268/// [`ResolvedInput`], ready for [`check`]. Shared by `cuttlefishd`'s run
269/// path and `cuttlefish build` — the only two callers, and both need the
270/// same resolve-then-load behavior.
271///
272/// An entry that resolves to a real path once joined against `spec_dir` is
273/// treated as that path — matching the join every other spec-relative
274/// reference (`capabilities`) already gets, so a relative block path means
275/// the same thing regardless of the process's working directory. This
276/// mirrors [`Catalog::resolve`]'s own Direct-vs-Cataloged decision (`s.ends_with(".wasm")
277/// || Path::new(s).exists()`), just relative to `spec_dir` instead of the
278/// process's CWD, rather than guessing from the raw string's shape (`entry`
279/// containing `/`) before `resolve` ever gets a look — a catalog name is
280/// free to contain `/` (a namespaced convention like `"team/cat-a@1"`), and
281/// joining on shape alone would corrupt such a name into a bogus path before
282/// the catalog's own index lookup ever sees it.
283///
284/// A `.wasm`/`.cfbundle` suffix is still always treated as a path reference
285/// even when the joined candidate doesn't exist on disk, so a genuinely
286/// missing compiled artifact still names the expected file in its error
287/// instead of silently falling back to a catalog lookup on a string that
288/// happens to contain that suffix. Anything else that doesn't resolve to a
289/// real file is passed to [`Catalog::resolve`] unmodified — joining it would
290/// turn `name@version` into `<spec_dir>/name@version`, which matches neither
291/// an index key nor a real file.
292pub fn resolve_and_load(
293 catalog: &Catalog,
294 spec_dir: &Path,
295 entry: &str,
296 context: ResolutionContext,
297) -> Result<ResolvedInput, PipelineError> {
298 let candidate = spec_dir.join(entry);
299 let use_joined = candidate.exists() || entry.ends_with(".wasm") || entry.ends_with(".cfbundle");
300 let joined;
301 let s: &str = if use_joined {
302 joined = candidate.to_string_lossy().into_owned();
303 &joined
304 } else {
305 entry
306 };
307
308 match catalog.resolve(s, context)? {
309 Resolved::Direct(path) => {
310 if path.is_dir() {
311 return Err(PipelineError::Uninspectable {
312 name: path.display().to_string(),
313 message: "this is a directory. A pipeline names compiled \
314 `.wasm`/`.cfbundle` artifacts, not block source \
315 directories — build the block first and point at \
316 the compiled artifact."
317 .into(),
318 });
319 }
320 let bytes = std::fs::read(&path).map_err(|source| PipelineError::Unreadable {
321 path: path.clone(),
322 source,
323 })?;
324 let kind = crate::catalog::sniff_artifact_kind(&bytes).ok_or_else(|| {
325 PipelineError::Uninspectable {
326 name: path.display().to_string(),
327 message: "not a recognized artifact (neither wasm nor .cfbundle magic \
328 bytes)"
329 .into(),
330 }
331 })?;
332 Ok(ResolvedInput {
333 name: name_of(&path),
334 kind,
335 resolved: None,
336 bytes,
337 script: None,
338 })
339 }
340 Resolved::Cataloged {
341 name_version,
342 entry,
343 } => {
344 let name = name_version
345 .split_once('@')
346 .map(|(n, _)| n)
347 .unwrap_or(&name_version)
348 .to_string();
349
350 if entry.kind == crate::catalog::ArtifactKind::Script {
351 let script_bytes = catalog.read_blob(&entry)?;
352 let script =
353 String::from_utf8(script_bytes).map_err(|e| PipelineError::Uninspectable {
354 name: name.clone(),
355 message: format!("cataloged script is not valid UTF-8: {e}"),
356 })?;
357 return Ok(ResolvedInput {
358 name,
359 kind: entry.kind,
360 resolved: Some(name_version),
361 bytes: crate::embedded_rhai_interpreter_bytes().to_vec(),
362 script: Some(script),
363 });
364 }
365
366 let bytes = catalog.read_blob(&entry)?;
367 Ok(ResolvedInput {
368 name,
369 kind: entry.kind,
370 resolved: Some(name_version),
371 bytes,
372 script: None,
373 })
374 }
375 }
376}
377
378/// A short name for error messages and manifest output — the file stem, or
379/// the whole path when there isn't one (`Path::file_stem(".wasm")` returns
380/// `Some(".wasm")` under the leading-dot rule, so this never panics or
381/// empties out on a no-real-basename path).
382fn name_of(path: &Path) -> String {
383 path.file_stem()
384 .map(|s| s.to_string_lossy().into_owned())
385 .unwrap_or_else(|| path.display().to_string())
386}