Skip to content

Commit d9361a7

Browse files
authored
Add support for calculating build order (#57)
This is basically oxidecomputer/crucible#1097 , but generalized and with tests Adds support for topologically sorting packages, so they can be built in dependency-first order. Fixes #56
1 parent f581faf commit d9361a7

File tree

4 files changed

+233
-27
lines changed

4 files changed

+233
-27
lines changed

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,4 +30,5 @@ tempfile = "3.4"
3030
thiserror = "1.0"
3131
tokio = { version = "1.26", features = [ "full" ] }
3232
toml = "0.7.3"
33+
topological-sort = "0.2.2"
3334
walkdir = "2.3"

src/config.rs

Lines changed: 209 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,97 @@
44

55
//! Configuration for a package.
66
7-
use crate::package::{Package, PackageOutput};
7+
use crate::package::{Package, PackageOutput, PackageSource};
88
use crate::target::Target;
99
use serde_derive::Deserialize;
1010
use std::collections::BTreeMap;
1111
use std::path::Path;
1212
use thiserror::Error;
13+
use topological_sort::TopologicalSort;
14+
15+
/// Describes a set of packages to act upon.
16+
pub struct PackageMap<'a>(BTreeMap<&'a String, &'a Package>);
17+
18+
// The name of a file which should be created by building a package.
19+
#[derive(Clone, Eq, Hash, Ord, PartialEq, PartialOrd)]
20+
struct OutputFile(String);
21+
22+
impl<'a> PackageMap<'a> {
23+
pub fn build_order(&self) -> PackageDependencyIter<'a> {
24+
let lookup_by_output = self
25+
.0
26+
.iter()
27+
.map(|(name, package)| (OutputFile(package.get_output_file(name)), (*name, *package)))
28+
.collect::<BTreeMap<_, _>>();
29+
30+
// Collect all packages, and sort them in dependency order,
31+
// so we know which ones to build first.
32+
let mut outputs = TopologicalSort::<OutputFile>::new();
33+
for (package_output, (_, package)) in &lookup_by_output {
34+
match &package.source {
35+
PackageSource::Local { .. }
36+
| PackageSource::Prebuilt { .. }
37+
| PackageSource::Manual => {
38+
// Skip intermediate leaf packages; if necessary they'll be
39+
// added to the dependency graph by whatever composite package
40+
// actually depends on them.
41+
if !matches!(
42+
package.output,
43+
PackageOutput::Zone {
44+
intermediate_only: true
45+
}
46+
) {
47+
outputs.insert(package_output.clone());
48+
}
49+
}
50+
PackageSource::Composite { packages: deps } => {
51+
for dep in deps {
52+
outputs.add_dependency(OutputFile(dep.clone()), package_output.clone());
53+
}
54+
}
55+
}
56+
}
57+
58+
PackageDependencyIter {
59+
lookup_by_output,
60+
outputs,
61+
}
62+
}
63+
}
64+
65+
/// Returns all packages in the order in which they should be built.
66+
///
67+
/// Returns packages in batches that may be built concurrently.
68+
pub struct PackageDependencyIter<'a> {
69+
lookup_by_output: BTreeMap<OutputFile, (&'a String, &'a Package)>,
70+
outputs: TopologicalSort<OutputFile>,
71+
}
72+
73+
impl<'a> Iterator for PackageDependencyIter<'a> {
74+
type Item = Vec<(&'a String, &'a Package)>;
75+
76+
fn next(&mut self) -> Option<Self::Item> {
77+
if self.outputs.is_empty() {
78+
return None;
79+
}
80+
let batch = self.outputs.pop_all();
81+
assert!(
82+
!batch.is_empty() || self.outputs.is_empty(),
83+
"cyclic dependency in package manifest!"
84+
);
85+
86+
Some(
87+
batch
88+
.into_iter()
89+
.map(|output| {
90+
*self.lookup_by_output.get(&output).unwrap_or_else(|| {
91+
panic!("Could not find a package which creates '{}'", output.0)
92+
})
93+
})
94+
.collect(),
95+
)
96+
}
97+
}
1398

1499
/// Describes the configuration for a set of packages.
15100
#[derive(Deserialize, Debug)]
@@ -21,24 +106,28 @@ pub struct Config {
21106

22107
impl Config {
23108
/// Returns target packages to be assembled on the builder machine.
24-
pub fn packages_to_build(&self, target: &Target) -> BTreeMap<&String, &Package> {
25-
self.packages
26-
.iter()
27-
.filter(|(_, pkg)| target.includes_package(pkg))
28-
.map(|(name, pkg)| (name, pkg))
29-
.collect()
109+
pub fn packages_to_build(&self, target: &Target) -> PackageMap<'_> {
110+
PackageMap(
111+
self.packages
112+
.iter()
113+
.filter(|(_, pkg)| target.includes_package(pkg))
114+
.map(|(name, pkg)| (name, pkg))
115+
.collect(),
116+
)
30117
}
31118

32119
/// Returns target packages which should execute on the deployment machine.
33-
pub fn packages_to_deploy(&self, target: &Target) -> BTreeMap<&String, &Package> {
34-
let all_packages = self.packages_to_build(target);
35-
all_packages
36-
.into_iter()
37-
.filter(|(_, pkg)| match pkg.output {
38-
PackageOutput::Zone { intermediate_only } => !intermediate_only,
39-
PackageOutput::Tarball => true,
40-
})
41-
.collect()
120+
pub fn packages_to_deploy(&self, target: &Target) -> PackageMap<'_> {
121+
let all_packages = self.packages_to_build(target).0;
122+
PackageMap(
123+
all_packages
124+
.into_iter()
125+
.filter(|(_, pkg)| match pkg.output {
126+
PackageOutput::Zone { intermediate_only } => !intermediate_only,
127+
PackageOutput::Tarball => true,
128+
})
129+
.collect(),
130+
)
42131
}
43132
}
44133

@@ -61,3 +150,107 @@ pub fn parse<P: AsRef<Path>>(path: P) -> Result<Config, ParseError> {
61150
let contents = std::fs::read_to_string(path.as_ref())?;
62151
parse_manifest(&contents)
63152
}
153+
154+
#[cfg(test)]
155+
mod test {
156+
use super::*;
157+
158+
#[test]
159+
fn test_order() {
160+
let pkg_a_name = String::from("pkg-a");
161+
let pkg_a = Package {
162+
service_name: String::from("a"),
163+
source: PackageSource::Manual,
164+
output: PackageOutput::Tarball,
165+
only_for_targets: None,
166+
setup_hint: None,
167+
};
168+
169+
let pkg_b_name = String::from("pkg-b");
170+
let pkg_b = Package {
171+
service_name: String::from("b"),
172+
source: PackageSource::Composite {
173+
packages: vec![pkg_a.get_output_file(&pkg_a_name)],
174+
},
175+
output: PackageOutput::Tarball,
176+
only_for_targets: None,
177+
setup_hint: None,
178+
};
179+
180+
let cfg = Config {
181+
packages: BTreeMap::from([
182+
(pkg_a_name.clone(), pkg_a.clone()),
183+
(pkg_b_name.clone(), pkg_b.clone()),
184+
]),
185+
};
186+
187+
let mut order = cfg.packages_to_build(&Target::default()).build_order();
188+
// "pkg-a" comes first, because "pkg-b" depends on it.
189+
assert_eq!(order.next(), Some(vec![(&pkg_a_name, &pkg_a)]));
190+
assert_eq!(order.next(), Some(vec![(&pkg_b_name, &pkg_b)]));
191+
}
192+
193+
// We're kinda limited by the topological-sort library here, as this is a documented
194+
// behavior from [TopologicalSort::pop_all].
195+
//
196+
// Regardless, test that circular dependencies cause panics.
197+
#[test]
198+
#[should_panic(expected = "cyclic dependency in package manifest")]
199+
fn test_cyclic_dependency() {
200+
let pkg_a_name = String::from("pkg-a");
201+
let pkg_b_name = String::from("pkg-b");
202+
let pkg_a = Package {
203+
service_name: String::from("a"),
204+
source: PackageSource::Composite {
205+
packages: vec![String::from("pkg-b.tar")],
206+
},
207+
output: PackageOutput::Tarball,
208+
only_for_targets: None,
209+
setup_hint: None,
210+
};
211+
let pkg_b = Package {
212+
service_name: String::from("b"),
213+
source: PackageSource::Composite {
214+
packages: vec![String::from("pkg-a.tar")],
215+
},
216+
output: PackageOutput::Tarball,
217+
only_for_targets: None,
218+
setup_hint: None,
219+
};
220+
221+
let cfg = Config {
222+
packages: BTreeMap::from([
223+
(pkg_a_name.clone(), pkg_a.clone()),
224+
(pkg_b_name.clone(), pkg_b.clone()),
225+
]),
226+
};
227+
228+
let mut order = cfg.packages_to_build(&Target::default()).build_order();
229+
order.next();
230+
}
231+
232+
// Make pkg-a depend on pkg-b.tar, but don't include pkg-b.tar anywhere.
233+
//
234+
// Ensure that we see an appropriate panic.
235+
#[test]
236+
#[should_panic(expected = "Could not find a package which creates 'pkg-b.tar'")]
237+
fn test_missing_dependency() {
238+
let pkg_a_name = String::from("pkg-a");
239+
let pkg_a = Package {
240+
service_name: String::from("a"),
241+
source: PackageSource::Composite {
242+
packages: vec![String::from("pkg-b.tar")],
243+
},
244+
output: PackageOutput::Tarball,
245+
only_for_targets: None,
246+
setup_hint: None,
247+
};
248+
249+
let cfg = Config {
250+
packages: BTreeMap::from([(pkg_a_name.clone(), pkg_a.clone())]),
251+
};
252+
253+
let mut order = cfg.packages_to_build(&Target::default()).build_order();
254+
order.next();
255+
}
256+
}

src/package.rs

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -176,7 +176,7 @@ async fn add_package_to_zone_archive(
176176
/// the following path:
177177
///
178178
/// <https://buildomat.eng.oxide.computer/public/file/oxidecomputer/REPO/SERIES/COMMIT/ARTIFACT>
179-
#[derive(Deserialize, Debug)]
179+
#[derive(Clone, Deserialize, Debug, PartialEq)]
180180
pub struct PrebuiltBlob {
181181
pub repo: String,
182182
pub series: String,
@@ -186,7 +186,7 @@ pub struct PrebuiltBlob {
186186
}
187187

188188
/// Describes the origin of an externally-built package.
189-
#[derive(Deserialize, Debug)]
189+
#[derive(Clone, Deserialize, Debug, PartialEq)]
190190
#[serde(tag = "type", rename_all = "lowercase")]
191191
pub enum PackageSource {
192192
/// Describes a package which should be assembled locally.
@@ -257,7 +257,7 @@ impl PackageSource {
257257
}
258258

259259
/// Describes the output format of the package.
260-
#[derive(Deserialize, Debug, Clone)]
260+
#[derive(Deserialize, Debug, Clone, PartialEq)]
261261
#[serde(tag = "type", rename_all = "lowercase")]
262262
pub enum PackageOutput {
263263
/// A complete zone image, ready to be deployed to the target.
@@ -274,7 +274,7 @@ pub enum PackageOutput {
274274
}
275275

276276
/// A single package.
277-
#[derive(Deserialize, Debug)]
277+
#[derive(Clone, Deserialize, Debug, PartialEq)]
278278
pub struct Package {
279279
/// The name of the service name to be used on the target OS.
280280
pub service_name: String,
@@ -821,7 +821,7 @@ impl Package {
821821
}
822822

823823
/// Describes configuration for a package which contains a Rust binary.
824-
#[derive(Deserialize, Debug)]
824+
#[derive(Clone, Deserialize, Debug, PartialEq)]
825825
pub struct RustPackage {
826826
/// The name of the compiled binary to be used.
827827
// TODO: Could be extrapolated to "produced build artifacts", we don't
@@ -869,7 +869,7 @@ impl RustPackage {
869869
}
870870

871871
/// A string which can be modified with key-value pairs.
872-
#[derive(Deserialize, Debug)]
872+
#[derive(Clone, Deserialize, Debug, PartialEq)]
873873
pub struct InterpolatedString(String);
874874

875875
impl InterpolatedString {
@@ -891,7 +891,10 @@ impl InterpolatedString {
891891
};
892892
let key = &input[..end_idx];
893893
let Some(value) = target.0.get(key) else {
894-
bail!("Key '{key}' not found in target, but required in '{}'", self.0);
894+
bail!(
895+
"Key '{key}' not found in target, but required in '{}'",
896+
self.0
897+
);
895898
};
896899
output.push_str(&value);
897900
input = &input[end_idx + END_STR.len()..];
@@ -902,7 +905,7 @@ impl InterpolatedString {
902905
}
903906

904907
/// A pair of paths, mapping from a directory on the host to the target.
905-
#[derive(Deserialize, Debug)]
908+
#[derive(Clone, Deserialize, Debug, PartialEq)]
906909
pub struct MappedPath {
907910
/// Source path.
908911
pub from: InterpolatedString,

tests/mod.rs

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -175,10 +175,16 @@ mod test {
175175
let cfg = config::parse("tests/service-e/cfg.toml").unwrap();
176176
let out = tempfile::tempdir().unwrap();
177177

178+
// Ask for the order of packages to-be-built
179+
let packages = cfg.packages_to_build(&Target::default());
180+
let mut build_order = packages.build_order();
181+
178182
// Build the dependencies first.
179-
let package_dependencies = ["pkg-1", "pkg-2"];
180-
for package_name in package_dependencies {
181-
let package = cfg.packages.get(package_name).unwrap();
183+
let batch = build_order.next().expect("Missing dependency batch");
184+
let mut batch_pkg_names: Vec<_> = batch.iter().map(|(name, _)| *name).collect();
185+
batch_pkg_names.sort();
186+
assert_eq!(batch_pkg_names, vec!["pkg-1", "pkg-2"]);
187+
for (package_name, package) in batch {
182188
// Create the packaged file
183189
package
184190
.create_for_target(&Target::default(), package_name, out.path())
@@ -187,7 +193,10 @@ mod test {
187193
}
188194

189195
// Build the composite package
196+
let batch = build_order.next().expect("Missing dependency batch");
197+
let batch_pkg_names: Vec<_> = batch.iter().map(|(name, _)| *name).collect();
190198
let package_name = "pkg-3";
199+
assert_eq!(batch_pkg_names, vec![package_name]);
191200
let package = cfg.packages.get(package_name).unwrap();
192201
package
193202
.create_for_target(&Target::default(), package_name, out.path())

0 commit comments

Comments
 (0)