forklaunch 1.35.0

Launch faster with forklaunch
//! Export using the container's freshly built native binary before it starts serving.
use std::{
    collections::BTreeMap,
    path::{Path, PathBuf},
};

use anyhow::{Context, Result, bail};
use serde_json::{Value, json};

use crate::core::{
    application_output::output_path,
    artifact_paths::{self, Artifact},
};

pub(super) fn quote(value: &str) -> String {
    format!("'{}'", value.replace('\'', "'\"'\"'"))
}

fn script(scratch: &Path, contract: &Path, openapi: &Path) -> Result<String> {
    let text = |path: &Path| -> Result<String> {
        Ok(quote(path.to_str().context("Export path must be UTF-8")?))
    };
    let contract_parent = contract.parent().context("Contract parent missing")?;
    let openapi_parent = openapi.parent().context("OpenAPI parent missing")?;
    Ok(format!(
        r#"mkdir -p {scratch} && export_dir=$(mktemp -d {scratch}/dev-export-XXXXXX) && trap 'rm -rf "$export_dir"' EXIT HUP INT TERM && FORKLAUNCH_MODE=contract FORKLAUNCH_CONTRACT_OUTPUT="$export_dir/contract.json" FORKLAUNCH_OPENAPI_OUTPUT="$export_dir/openapi.json" "$1" && test -f "$export_dir/contract.json" && test -f "$export_dir/openapi.json" && mkdir -p {contract_parent} {openapi_parent} && (cmp -s "$export_dir/contract.json" {contract} || mv -f "$export_dir/contract.json" {contract}) && (cmp -s "$export_dir/openapi.json" {openapi} || mv -f "$export_dir/openapi.json" {openapi}) && rm -f "$export_dir/contract.json" "$export_dir/openapi.json" && rmdir "$export_dir" && trap - EXIT HUP INT TERM"#,
        scratch = text(scratch)?,
        contract_parent = text(contract_parent)?,
        openapi_parent = text(openapi_parent)?,
        contract = text(contract)?,
        openapi = text(openapi)?
    ))
}

pub(super) fn resolved_output(path: &Path) -> Result<PathBuf> {
    let mut existing = path;
    let mut suffix = Vec::new();
    while !existing.exists() {
        suffix.push(existing.file_name().context("Invalid artifact path")?);
        existing = existing.parent().context("Artifact parent missing")?;
    }
    if !suffix.is_empty() && !existing.is_dir() {
        bail!("Artifact parent must be a directory");
    }
    let mut resolved = existing.canonicalize()?;
    for component in suffix.into_iter().rev() {
        resolved.push(component);
    }
    Ok(resolved)
}

/// A project-local Rust SDK whose build script emits the service's contract
/// and OpenAPI artifacts (`build::service_sdks_at`).
fn build_emits_artifacts(root: &Path, project: &toml::Value) -> bool {
    let metadata = project.get("metadata");
    let field = |name: &str| metadata.and_then(|m| m.get(name)).and_then(toml::Value::as_str);
    if field("language") != Some("rust") || field("sdkLayout") != Some("project-local-v1") {
        return false;
    }
    let Some(crate_path) = field("cratePath") else { return false };
    std::fs::read_to_string(root.join(crate_path).join("sdk/rust/build.rs"))
        .is_ok_and(|source| source.contains("service_sdks_at("))
}

/// Shell `forklaunch-native-run` evaluates before each process start.
pub(super) const PRESTART: &str = "FORKLAUNCH_DEV_PRESTART";

enum Startup {
    /// `--no-watch`: the export joins the one-shot build-and-exec entrypoint.
    Entrypoint(String),
    /// Reloads: the export runs before every in-place restart.
    Prestart(String),
}

pub(super) fn enable(
    root: &Path,
    manifest: &toml::Value,
    compose: &mut Value,
    changes: &mut Value,
) -> Result<()> {
    let projects = manifest
        .get("projects")
        .and_then(toml::Value::as_array)
        .context("Projects missing")?;
    let scratch = resolved_output(&output_path(root, Path::new(".forklaunch"))?)?;
    if scratch.exists() && !scratch.is_dir() {
        bail!("Development export scratch path must be a directory");
    }
    let scratch = Path::new("/app").join(scratch.strip_prefix(root.canonicalize()?)?);
    let mut destinations = BTreeMap::<PathBuf, String>::new();
    let mut plans = Vec::new();
    for (service, change) in changes
        .as_object()
        .context("Native development changes missing")?
    {
        let binary = change["command"][0]
            .as_str()
            .and_then(|path| Path::new(path).file_name())
            .and_then(|name| name.to_str())
            .context("Native binary missing")?;
        let project = projects
            .iter()
            .find(|project| project.get("name").and_then(toml::Value::as_str) == Some(binary))
            .context("Native project missing")?;
        let metadata = project.get("metadata");
        if build_emits_artifacts(root, project) {
            // Cargo's SDK build script already writes these artifacts on every
            // rebuild. A second, differently serialized startup export would
            // alternate with it, and each alternation would regenerate SDKs and
            // restart the service, forever.
            continue;
        }
        let mut paths = Vec::new();
        for (field, alias, default) in [
            (
                "contractArtifactPath",
                "contract_artifact_path",
                artifact_paths::default_path(
                    binary,
                    metadata.and_then(toml::Value::as_table),
                    Artifact::Contract,
                ),
            ),
            (
                "openapiPath",
                "openapi_path",
                artifact_paths::default_path(
                    binary,
                    metadata.and_then(toml::Value::as_table),
                    Artifact::OpenApi,
                ),
            ),
        ] {
            let configured = metadata
                .and_then(|value| value.get(field).or_else(|| value.get(alias)))
                .and_then(toml::Value::as_str)
                .unwrap_or(&default);
            let destination = resolved_output(&output_path(root, Path::new(configured))?)?;
            if destination.exists() && !destination.is_file() {
                bail!("Artifact destination must be a file");
            }
            let owner = format!("{binary}:{field}");
            if let Some(previous) = destinations.insert(destination.clone(), owner.clone())
                && previous != owner
            {
                bail!("Development artifact collision between {previous} and {owner}");
            }
            paths.push(Path::new("/app").join(destination.strip_prefix(root.canonicalize()?)?));
        }
        let export = script(&scratch, &paths[0], &paths[1])?;
        if change["entrypoint"][0] == "forklaunch-native-run" {
            // Reloading processes export before every in-place restart.
            plans.push((service.clone(), Startup::Prestart(export.replace('$', "$$"))));
            continue;
        }
        let startup = change["entrypoint"][2]
            .as_str()
            .context("Native startup missing")?;
        let build = startup
            .strip_suffix(" && exec \"$@\"")
            .context("Unsupported native startup")?;
        plans.push((
            service.clone(),
            Startup::Entrypoint(format!("{build} && {export} && exec \"$@\"").replace('$', "$$")),
        ));
    }
    for (service, startup) in plans {
        match startup {
            Startup::Entrypoint(script) => {
                compose["services"][&service]["entrypoint"][2] = json!(script);
                changes[&service]["entrypoint"][2] = json!(script);
            }
            Startup::Prestart(script) => {
                compose["services"][&service]["environment"][PRESTART] = json!(script);
                changes[&service]["environment"][PRESTART] = json!(script);
            }
        }
    }
    Ok(())
}

#[cfg(test)]
mod tests {
    use std::{fs, process::Command};

    use super::*;

    #[cfg(unix)]
    #[test]
    fn exports_before_startup_and_preserves_old_outputs_on_export_failure() {
        use std::os::unix::fs::PermissionsExt;
        let root = tempfile::tempdir().unwrap();
        let binary = root.path().join("service");
        let contract = root.path().join("quoted ' directory/contract.json");
        let openapi = root.path().join("specs/openapi.json");
        fs::write(
            &binary,
            r#"#!/bin/sh
if [ "$FORKLAUNCH_MODE" = contract ]; then
  printf contract > "$FORKLAUNCH_CONTRACT_OUTPUT"
  test "$FAIL_EXPORT" != 1 || exit 1
  printf openapi > "$FORKLAUNCH_OPENAPI_OUTPUT"
else
  test "$1" = --worker
fi
"#,
        )
        .unwrap();
        fs::set_permissions(&binary, fs::Permissions::from_mode(0o700)).unwrap();
        let scratch = root.path().join("scratch");
        let startup = format!(
            "{} && exec \"$@\"",
            script(&scratch, &contract, &openapi).unwrap()
        );
        let run = |fail: &str| {
            Command::new("sh")
                .args(["-c", &startup, "--"])
                .arg(&binary)
                .arg("--worker")
                .env("FAIL_EXPORT", fail)
                .status()
                .unwrap()
        };
        assert!(run("0").success());
        assert_eq!(fs::read_to_string(&contract).unwrap(), "contract");
        assert_eq!(fs::read_to_string(&openapi).unwrap(), "openapi");
        let timestamp = std::time::UNIX_EPOCH + std::time::Duration::from_secs(123456789);
        fs::File::options()
            .write(true)
            .open(&contract)
            .unwrap()
            .set_times(fs::FileTimes::new().set_modified(timestamp))
            .unwrap();
        assert!(run("0").success());
        assert_eq!(
            fs::metadata(&contract).unwrap().modified().unwrap(),
            timestamp
        );
        fs::write(&contract, "old contract").unwrap();
        fs::write(&openapi, "old openapi").unwrap();
        assert!(!run("1").success());
        assert_eq!(fs::read_to_string(contract).unwrap(), "old contract");
        assert_eq!(fs::read_to_string(openapi).unwrap(), "old openapi");
        assert_eq!(fs::read_dir(scratch).unwrap().count(), 0);
    }
}

#[cfg(test)]
mod planning_tests {
    use super::*;
    #[test]
    fn custom_paths_are_read_only_and_collisions_fail_before_plan_mutation() {
        let (root, mut manifest, base) = super::super::tests::fixture();
        manifest["projects"][0]["metadata"]
            .as_table_mut()
            .unwrap()
            .insert(
                "contractArtifactPath".into(),
                toml::Value::String("custom/api.json".into()),
            );
        manifest["projects"][0]["metadata"]
            .as_table_mut()
            .unwrap()
            .insert(
                "openapiPath".into(),
                toml::Value::String("custom/spec.json".into()),
            );
        let (mut planned, mut changes) =
            super::super::plan(root.path(), &manifest, base, &[], true).unwrap();
        let original = planned.clone();
        enable(root.path(), &manifest, &mut planned, &mut changes).unwrap();
        let startup = planned["services"]["native-api"]["entrypoint"][2]
            .as_str()
            .unwrap();
        assert!(startup.contains("/app/custom/api.json"));
        assert!(startup.contains("/app/custom/spec.json"));
        assert!(startup.contains("\"$$export_dir/contract.json\""));
        assert_eq!(
            planned["services"]["legacy-api"],
            original["services"]["legacy-api"]
        );
        assert!(!root.path().join("custom").exists());
        manifest["projects"][0]["metadata"]["openapiPath"] =
            toml::Value::String("custom/api.json".into());
        let before = changes.clone();
        assert!(enable(root.path(), &manifest, &mut planned, &mut changes).is_err());
        assert_eq!(changes, before);
        manifest["projects"][0]["metadata"]["openapiPath"] =
            toml::Value::String("../outside.json".into());
        assert!(enable(root.path(), &manifest, &mut planned, &mut changes).is_err());
    }
}