Skip to main content

sofie_cli/
build_command.rs

1use std::fs;
2use std::io::ErrorKind;
3use std::path::{Path, PathBuf};
4use std::process::Command;
5
6use sofie_check::Rule;
7use sofie_model::App;
8
9use crate::cargo::{self, Diagnostics};
10use crate::check_command::check_model;
11use crate::dockerfile::{
12    DOCKERIGNORE, UNPINNED, dockerfile, island_toolchain, refuse_paths_outside,
13};
14use crate::gen_command::{GenError, io_error, read_existing};
15use crate::pgo;
16use crate::source_edits::read;
17
18/// The app's own binary, or `<app>-worker` (`worker`); `pgo` rebuilds it with
19/// a profile recorded under `sofie bench`'s load.
20#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
21pub struct BuildOptions {
22    pub image: bool,
23    pub worker: bool,
24    pub pgo: bool,
25}
26
27/// The cargo feature an app's worker binary requires, so other builds skip it.
28const WORKER_FEATURE: &str = "worker-binary";
29const DIST_PROFILE: &str = "dist";
30const DIST_SECTION: &str =
31    "[profile.dist]\ninherits = \"release\"\nlto = \"fat\"\ncodegen-units = 1\nstrip = \"symbols\"";
32
33#[derive(Debug)]
34pub enum Built {
35    Binary(Binary),
36    Image(Image),
37}
38
39#[derive(Debug)]
40pub struct Binary {
41    pub path: PathBuf,
42    pub bytes: u64,
43}
44
45#[derive(Debug, PartialEq, Eq)]
46pub enum Image {
47    Built {
48        tag: String,
49    },
50    Unpinned {
51        dockerfile: PathBuf,
52    },
53    NoDocker {
54        dockerfile: PathBuf,
55        command: String,
56    },
57}
58
59/// `--image` builds nothing on the host: the image builds the binary itself.
60pub fn build(root: &Path, options: BuildOptions) -> Result<Built, GenError> {
61    let app = sofie_model::load(root).map_err(GenError::Model)?;
62    if !options.image {
63        return build_binary(root, &app, options).map(Built::Binary);
64    }
65    if options.worker || options.pgo {
66        return Err(GenError::Usage(
67            "`--image` builds the app's binary inside the image, without `--worker` or `--pgo`"
68                .to_owned(),
69        ));
70    }
71    refuse_findings(root, &app)?;
72    let context =
73        sofie_model::workspace_root(&cargo::app_manifest(root)).map_err(GenError::Model)?;
74    refuse_missing_dist_profile(&context)?;
75    refuse_paths_outside(root, &context)?;
76    build_image(root, &app, &context).map(Built::Image)
77}
78
79pub(crate) fn build_binary(
80    root: &Path,
81    app: &App,
82    options: BuildOptions,
83) -> Result<Binary, GenError> {
84    refuse_findings(root, app)?;
85    let workspace = sofie_model::workspace_root(&root.join("app").join("Cargo.toml"))
86        .map_err(GenError::Model)?;
87    refuse_missing_dist_profile(&workspace)?;
88    let package = &app.app.package;
89    let worker = format!("{package}-worker");
90    let mut arguments = vec!["--profile", DIST_PROFILE, "--bin"];
91    if options.worker {
92        refuse_missing_worker(root, &worker, app.has_feature_flags())?;
93        arguments.extend([worker.as_str(), "--features", WORKER_FEATURE]);
94    } else {
95        arguments.push(package);
96    }
97    let path = if options.pgo {
98        pgo::build(root, app, &arguments)?
99    } else {
100        cargo::build(root, &arguments, Diagnostics::Terminal)?
101    };
102    let bytes = fs::metadata(&path)
103        .map_err(|source| io_error(&path, source))?
104        .len();
105    Ok(Binary { path, bytes })
106}
107
108/// Cargo refuses an undefined profile tersely; this names what to add.
109fn refuse_missing_dist_profile(workspace: &Path) -> Result<(), GenError> {
110    let manifest = workspace.join("Cargo.toml");
111    if read(&manifest)?
112        .lines()
113        .any(|line| line.trim() == "[profile.dist]")
114    {
115        return Ok(());
116    }
117    Err(GenError::Usage(format!(
118        "{} defines no `dist` profile, which `sofie build` builds with; add:\n\n{DIST_SECTION}",
119        manifest.display()
120    )))
121}
122
123/// What `sofie check` reports, so a release never embeds wiring that disagrees
124/// with the code; stale files alone name `sofie gen` as the fix.
125fn refuse_findings(root: &Path, app: &App) -> Result<(), GenError> {
126    let findings = check_model(root, app)?;
127    let stale: Option<Vec<String>> = findings
128        .iter()
129        .map(|finding| {
130            (finding.rule == Rule::StaleWiring).then(|| finding.file.display().to_string())
131        })
132        .collect();
133    match stale {
134        _ if findings.is_empty() => Ok(()),
135        Some(stale) => Err(GenError::App(format!(
136            "stale generated files: {}; run `sofie gen {}`",
137            stale.join(", "),
138            root.display()
139        ))),
140        None => Err(GenError::Check(findings)),
141    }
142}
143
144/// Names the two pieces cargo's own "no bin target" error would leave the reader to find.
145fn refuse_missing_worker(root: &Path, worker: &str, flags: bool) -> Result<(), GenError> {
146    let manifest = cargo::app_manifest(root);
147    if read(&manifest)?.contains(&format!("name = \"{worker}\"")) {
148        return Ok(());
149    }
150    // The generated `worker` takes the flags then, which `run_worker` cannot hand it.
151    let run = if flags {
152        "run_worker_with_flags"
153    } else {
154        "run_worker"
155    };
156    Err(GenError::Usage(format!(
157        "{} declares no `{worker}` binary; add `src/bin/worker.rs` calling \
158         `sofie::{run}(MODULES, worker)` and declare it:\n\
159         [features]\n{WORKER_FEATURE} = []\n\n\
160         [[bin]]\nname = \"{worker}\"\npath = \"src/bin/worker.rs\"\n\
161         required-features = [\"{WORKER_FEATURE}\"]",
162        manifest.display()
163    )))
164}
165
166/// The app reaches the framework and its modules by path, so the image builds
167/// from the workspace `context`.
168fn build_image(root: &Path, app: &App, context: &Path) -> Result<Image, GenError> {
169    let path = root.join("Dockerfile");
170    let text = match read_existing(&path)? {
171        Some(text) => text,
172        None => write_dockerfile(app, context, &path)?,
173    };
174    if text.contains(UNPINNED) {
175        return Ok(Image::Unpinned { dockerfile: path });
176    }
177    let tag = image_tag(&app.app.package);
178    let built = Command::new("docker")
179        .arg("build")
180        .arg("--file")
181        .arg(&path)
182        .args(["--tag", &tag])
183        .arg(context)
184        .status();
185    let status = match built {
186        Err(error) if error.kind() == ErrorKind::NotFound => {
187            let command = format!(
188                "docker build --file {} --tag {tag} {}",
189                path.display(),
190                context.display()
191            );
192            return Ok(Image::NoDocker {
193                dockerfile: path,
194                command,
195            });
196        }
197        built => built.map_err(|source| io_error(&path, source))?,
198    };
199    if !status.success() {
200        return Err(GenError::App(format!(
201            "`docker build` failed with {status}"
202        )));
203    }
204    Ok(Image::Built { tag })
205}
206
207/// Docker names an image in lowercase, starting with a letter or digit; a cargo package may
208/// have capitals and start with `_`.
209fn image_tag(package: &str) -> String {
210    package
211        .trim_start_matches(|character: char| !character.is_ascii_alphanumeric())
212        .to_lowercase()
213}
214
215fn write_dockerfile(app: &App, context: &Path, path: &Path) -> Result<String, GenError> {
216    let toolchain = island_toolchain(app, context)?;
217    let text = dockerfile(&app.app.package, toolchain.as_deref());
218    fs::write(path, &text).map_err(|source| io_error(path, source))?;
219    let ignore = path.with_file_name("Dockerfile.dockerignore");
220    fs::write(&ignore, DOCKERIGNORE).map_err(|source| io_error(&ignore, source))?;
221    Ok(text)
222}
223
224#[cfg(test)]
225mod tests {
226    use std::fs;
227
228    use sofie_model::testing::TemporaryDirectory;
229
230    use super::{image_tag, refuse_missing_dist_profile, refuse_missing_worker};
231
232    #[test]
233    fn a_missing_worker_binary_is_told_to_hand_flags_when_the_app_has_them() {
234        let directory = TemporaryDirectory::new("sofie-worker-binary");
235        directory.write("app/Cargo.toml", "[package]\nname = \"shop\"\n");
236
237        let plain = refuse_missing_worker(directory.root(), "shop-worker", false)
238            .unwrap_err()
239            .to_string();
240        let flagged = refuse_missing_worker(directory.root(), "shop-worker", true)
241            .unwrap_err()
242            .to_string();
243
244        assert!(
245            plain.contains("`sofie::run_worker(MODULES, worker)`"),
246            "{plain}"
247        );
248        assert!(
249            flagged.contains("`sofie::run_worker_with_flags(MODULES, worker)`"),
250            "{flagged}"
251        );
252    }
253
254    #[test]
255    fn an_image_is_tagged_with_the_package_name_lowercased() {
256        assert_eq!(image_tag("My_Shop"), "my_shop");
257        assert_eq!(image_tag("_Shop-2"), "shop-2");
258    }
259
260    #[test]
261    fn a_workspace_without_a_dist_profile_is_told_what_to_add() {
262        let workspace = std::env::temp_dir().join(format!("sofie-dist-{}", std::process::id()));
263        fs::create_dir_all(&workspace).unwrap();
264        let manifest = workspace.join("Cargo.toml");
265
266        fs::write(&manifest, "[profile.release]\nlto = \"fat\"\n").unwrap();
267        let missing = refuse_missing_dist_profile(&workspace)
268            .unwrap_err()
269            .to_string();
270        fs::write(&manifest, "[profile.dist]\ninherits = \"release\"\n").unwrap();
271        let present = refuse_missing_dist_profile(&workspace);
272        fs::remove_dir_all(&workspace).unwrap();
273
274        assert!(
275            missing.contains("[profile.dist]\ninherits = \"release\""),
276            "{missing}"
277        );
278        assert!(present.is_ok());
279    }
280}