runmat_execution_artifact/bundle/
builder.rs1use std::path::Path;
2
3use runmat_execution::{Digest, ProgramRevision};
4use runmat_package::{FrozenProject, FrozenProjectHandoff};
5
6use crate::bundle::{
7 BuildResourceDeclaration, BundleCallable, BundleCodeClosure, BundleManifest,
8 CompiledPackageClosure, ExecutionBundle, ProjectRevisionRecord,
9 EXECUTION_BUNDLE_SCHEMA_VERSION,
10};
11use crate::{
12 ArtifactError, ArtifactResult, ExecutableForm, LogicalObject, ObjectNamespace, ProgramArtifact,
13 ProgramBuildRecipe,
14};
15
16struct Materialization {
17 recipe: ProgramBuildRecipe,
18 form: ExecutableForm,
19 executable_bytes: Vec<u8>,
20}
21
22#[derive(Clone, Copy, Debug, Eq, PartialEq)]
23enum CodeClosureMode {
24 SourceProject,
25 Compiled,
26}
27
28pub trait SourceReader {
29 fn read(&self, path: &Path) -> ArtifactResult<Vec<u8>>;
30}
31
32impl<F> SourceReader for F
33where
34 F: Fn(&Path) -> ArtifactResult<Vec<u8>>,
35{
36 fn read(&self, path: &Path) -> ArtifactResult<Vec<u8>> {
37 self(path)
38 }
39}
40
41pub struct ExecutionBundleBuilder<'a, R> {
42 project: &'a FrozenProject,
43 revision: ProgramRevision,
44 reader: R,
45 recipes: Vec<ProgramBuildRecipe>,
46 materializations: Vec<Materialization>,
47 resources: BuildResourceDeclaration,
48 code_closure: CodeClosureMode,
49}
50
51impl<'a, R: SourceReader> ExecutionBundleBuilder<'a, R> {
52 pub fn new(
53 project: &'a FrozenProject,
54 revision: ProgramRevision,
55 reader: R,
56 ) -> ArtifactResult<Self> {
57 let project_revision = project.revision();
58 let exact_project_sources =
59 revision.source_digest().bytes() == project_revision.source_revision.bytes();
60 let exact_test_overlay = revision.domain_contribution("runmat.test.config").is_some();
61 if revision.graph_digest().bytes() != project_revision.graph_digest.bytes()
62 || (!exact_project_sources && !exact_test_overlay)
63 {
64 return Err(ArtifactError::Identity(
65 "program and frozen-project revisions differ without an exact test overlay".into(),
66 ));
67 }
68 Ok(Self {
69 project,
70 revision,
71 reader,
72 recipes: Vec::new(),
73 materializations: Vec::new(),
74 resources: BuildResourceDeclaration {
75 cpu_millicores: 1000,
76 memory_bytes: 1024 * 1024 * 1024,
77 scratch_bytes: 1024 * 1024 * 1024,
78 },
79 code_closure: CodeClosureMode::SourceProject,
80 })
81 }
82
83 pub fn with_recipe(mut self, recipe: ProgramBuildRecipe) -> Self {
84 self.recipes.push(recipe);
85 self
86 }
87
88 pub fn with_materialized_program(
89 mut self,
90 recipe: ProgramBuildRecipe,
91 form: ExecutableForm,
92 executable_bytes: Vec<u8>,
93 ) -> Self {
94 self.materializations.push(Materialization {
95 recipe,
96 form,
97 executable_bytes,
98 });
99 self
100 }
101
102 pub fn with_resources(mut self, resources: BuildResourceDeclaration) -> Self {
103 self.resources = resources;
104 self
105 }
106
107 pub fn with_compiled_package_closure(mut self) -> Self {
112 self.code_closure = CodeClosureMode::Compiled;
113 self
114 }
115
116 pub fn build(mut self) -> ArtifactResult<ExecutionBundle> {
117 if self.code_closure == CodeClosureMode::Compiled
118 && (self.materializations.is_empty()
119 || self.materializations.iter().any(|materialization| {
120 !matches!(
121 materialization.form,
122 ExecutableForm::ExecutableUnitV3
123 | ExecutableForm::NativeObjectV1
124 | ExecutableForm::MeshingWorkload
125 )
126 }))
127 {
128 return Err(ArtifactError::Invalid(
129 "compiled package closure requires a compiled executable-unit or native-object artifact"
130 .into(),
131 ));
132 }
133 let mut objects = Vec::new();
134 let mut callables = Vec::new();
135 let code_closure = match self.code_closure {
136 CodeClosureMode::SourceProject => {
137 let mut project_handoff = FrozenProjectHandoff::new(self.project.clone());
138 project_handoff.project.manifest_path = "runmat.toml".into();
139 project_handoff.project.workspace_root = ".".into();
140 for package in self.project.sources.packages.values() {
141 for source in &package.sources {
142 let path = self.project.access_paths.get(&source.id).ok_or_else(|| {
143 ArtifactError::Invalid(format!(
144 "source {} has no frozen access path",
145 source.id.relative_path
146 ))
147 })?;
148 let bytes = self.reader.read(path)?;
149 if source.id.content_digest.bytes() != Digest::sha256(&bytes).bytes() {
150 return Err(ArtifactError::Identity(format!(
151 "source {} changed after project freeze",
152 source.id.relative_path
153 )));
154 }
155 let logical_name =
156 format!("{}/{}", package.mount.logical_root, source.id.relative_path);
157 project_handoff
158 .project
159 .access_paths
160 .insert(source.id.clone(), logical_name.clone().into());
161 objects.push(LogicalObject::new(
162 ObjectNamespace::ProgramSource,
163 logical_name,
164 "text/x-matlab",
165 bytes,
166 )?);
167 callables.push(BundleCallable {
168 owner_identity: package.package_instance.to_string(),
169 qualified_name: source.qualified_name.clone(),
170 source_digest: Digest::from_bytes(*source.id.content_digest.bytes()),
171 });
172 }
173 }
174 BundleCodeClosure::SourceProject {
175 handoff: project_handoff,
176 }
177 }
178 CodeClosureMode::Compiled => {
179 let mut package_instances = self
180 .project
181 .graph
182 .packages
183 .values()
184 .map(|package| package.instance.to_string())
185 .collect::<Vec<_>>();
186 package_instances.sort();
187 package_instances.dedup();
188 BundleCodeClosure::Compiled {
189 package: CompiledPackageClosure {
190 schema_version: CompiledPackageClosure::SCHEMA_VERSION,
191 graph_digest: Digest::from_bytes(*self.project.graph.graph_digest.bytes()),
192 source_digest: Digest::from_bytes(*self.project.sources.revision.bytes()),
193 package_instances,
194 },
195 }
196 }
197 };
198 objects.sort_by(|left, right| left.descriptor.cmp(&right.descriptor));
199 callables.sort();
200 let source_descriptors = objects
201 .iter()
202 .map(|object| object.descriptor.clone())
203 .collect::<Vec<_>>();
204 for materialization in &mut self.materializations {
205 attach_source_closure(
206 &mut materialization.recipe,
207 &self.revision,
208 &source_descriptors,
209 )?;
210 }
211 for recipe in &mut self.recipes {
212 attach_source_closure(recipe, &self.revision, &source_descriptors)?;
213 }
214 let mut artifacts = Vec::with_capacity(self.materializations.len());
215 for materialization in self.materializations {
216 let artifact = ProgramArtifact::materialize(
217 &materialization.recipe,
218 materialization.form,
219 materialization.executable_bytes,
220 )?;
221 self.recipes.push(materialization.recipe);
222 artifacts.push(artifact);
223 }
224 let mut keyed_recipes = self
225 .recipes
226 .into_iter()
227 .map(|recipe| Ok((recipe.id()?, recipe)))
228 .collect::<ArtifactResult<Vec<_>>>()?;
229 keyed_recipes.sort_by_key(|(id, _)| *id);
230 let recipes = keyed_recipes
231 .into_iter()
232 .map(|(_, recipe)| recipe)
233 .collect();
234 artifacts.sort_by_key(|artifact| artifact.id);
235 let project_revision = self.project.revision();
236 let manifest = BundleManifest {
237 schema_version: EXECUTION_BUNDLE_SCHEMA_VERSION,
238 program_revision: self.revision,
239 project_revision: ProjectRevisionRecord {
240 graph_digest: Digest::from_bytes(*project_revision.graph_digest.bytes()),
241 source_digest: Digest::from_bytes(*project_revision.source_revision.bytes()),
242 },
243 code_closure,
244 sources: source_descriptors,
245 callables,
246 recipes,
247 artifacts,
248 requested_capabilities: Default::default(),
249 resources: self.resources,
250 portable_environment: Vec::new(),
251 };
252 let bundle = ExecutionBundle { manifest, objects };
253 bundle.validate()?;
254 Ok(bundle)
255 }
256}
257
258fn attach_source_closure(
259 recipe: &mut ProgramBuildRecipe,
260 revision: &ProgramRevision,
261 source_descriptors: &[crate::ObjectDescriptor],
262) -> ArtifactResult<()> {
263 if &recipe.program_revision != revision {
264 return Err(ArtifactError::Identity(
265 "program recipe revision differs from the bundle".into(),
266 ));
267 }
268 if recipe.source_objects.is_empty() {
269 recipe.source_objects = source_descriptors.to_vec();
270 } else if recipe.source_objects != source_descriptors {
271 return Err(ArtifactError::Identity(
272 "program recipe source closure differs from the frozen project".into(),
273 ));
274 }
275 Ok(())
276}
277
278#[cfg(not(target_arch = "wasm32"))]
279impl<'a> ExecutionBundleBuilder<'a, fn(&Path) -> ArtifactResult<Vec<u8>>> {
280 pub fn native(project: &'a FrozenProject, revision: ProgramRevision) -> ArtifactResult<Self> {
281 Self::new(project, revision, native_read)
282 }
283}
284
285#[cfg(not(target_arch = "wasm32"))]
286fn native_read(path: &Path) -> ArtifactResult<Vec<u8>> {
287 std::fs::read(path).map_err(Into::into)
288}