Skip to main content

outrig_cli/image_setup/
build.rs

1//! `outrig image build` -- build a standalone image project and validate it.
2//!
3//! Reads `PROJECT_DIR/image.toml`, serializes it into OCI labels, builds the
4//! declared image with buildah (tagged by `[image].ref`, or `--tag <ref>`, and
5//! stamped with those labels), then starts a throwaway container to prove the
6//! result is a usable OutRig toolset image: it must carry a valid
7//! `org.outrig.mcp` label, and -- unless `--no-test` -- every declared MCP
8//! server must initialize and answer `tools/list`.
9//!
10//! Unlike repo-local `outrig build`, there is no content-addressed cache: the
11//! output is a caller-named ref, so `--no-cache` only forwards to buildah.
12
13use std::collections::BTreeMap;
14use std::path::Path;
15use std::time::Duration;
16
17use outrig::McpClient;
18use outrig::container::embedded::{
19    StandaloneImageToml, parse_standalone_image_toml, read_standalone_image_mcp,
20    standalone_config_to_labels,
21};
22use outrig::container::{Container, ContainerLaunchSpec};
23use outrig::image::{self, ImageTag};
24
25use crate::cli::session_setup::plural;
26use crate::error::{OutrigError, Result};
27
28const STOP_GRACE: Duration = Duration::from_secs(2);
29
30/// Build the standalone image project in `dir` (relative to `cwd`, or `cwd`
31/// itself when `dir` is `None`), then validate the built image.
32pub async fn run(
33    cwd: &Path,
34    dir: Option<&Path>,
35    tag_override: Option<&str>,
36    no_test: bool,
37    no_cache: bool,
38) -> Result<()> {
39    let project_dir = dir.map_or_else(|| cwd.to_path_buf(), |d| cwd.join(d));
40    let parsed = load_project_image_toml(&project_dir)?;
41    let labels = standalone_config_to_labels(&parsed)?;
42    let tag = ImageTag(
43        tag_override
44            .map(str::to_string)
45            .unwrap_or_else(|| parsed.image.image_ref.clone()),
46    );
47
48    eprintln!("[outrig] building image {tag}");
49    eprintln!(
50        "[outrig]   dockerfile: {}",
51        parsed.build.dockerfile.display()
52    );
53    eprintln!("[outrig]   context:    {}", parsed.build.context.display());
54    image::build_standalone(
55        &project_dir,
56        &parsed.build.dockerfile,
57        &parsed.build.context,
58        &tag,
59        no_cache,
60        &labels,
61    )
62    .await?;
63    eprintln!("[outrig] image ready");
64
65    // One throwaway tempdir holds both the workspace bind-mount target and the
66    // per-server MCP stderr logs; it (and the logs) vanish when `run` returns.
67    // Startup/tools-list failures are already enriched into the returned error,
68    // so discarding the happy-path logs is fine for a validation command.
69    let scratch = tempfile::tempdir()?;
70    let host_ws = scratch.path().join("workspace");
71    std::fs::create_dir_all(&host_ws)?;
72    let log_dir = scratch.path().join("logs");
73
74    let mut container = Container::start(
75        &tag,
76        ContainerLaunchSpec::workspace(&host_ws, Path::new("/workspace")),
77    )
78    .await?;
79
80    // Validate (and optionally test) against the container, then tear down on
81    // both paths -- the validation error wins over a stop error. `Drop` is the
82    // backstop for the window between `start` and `stop`.
83    let outcome = validate_and_test(&mut container, &log_dir, no_test).await;
84    let stop = container.stop(STOP_GRACE).await;
85    outcome?;
86    stop?;
87
88    eprintln!("[outrig] image ok");
89    Ok(())
90}
91
92/// Read and parse the project's own `image.toml`. Failures (missing file,
93/// malformed TOML, missing required fields) are framed against the project path
94/// -- this is the user's input, distinct from the stamped labels validated
95/// later via [`read_standalone_image_mcp`].
96fn load_project_image_toml(project_dir: &Path) -> Result<StandaloneImageToml> {
97    let path = project_dir.join("image.toml");
98    let text = std::fs::read_to_string(&path).map_err(|source| {
99        OutrigError::Configuration(format!("could not read {}: {source}", path.display()))
100    })?;
101    let parsed = parse_standalone_image_toml(&text)
102        .map_err(|source| OutrigError::Configuration(format!("{}: {source}", path.display())))?;
103    Ok(parsed)
104}
105
106/// Read+validate the stamped `org.outrig.mcp` label (always), then -- unless
107/// `no_test` -- start each declared MCP server and report its tool count.
108async fn validate_and_test(container: &mut Container, log_dir: &Path, no_test: bool) -> Result<()> {
109    let mcp = read_standalone_image_mcp(container).await?;
110    let count = mcp.len();
111    eprintln!(
112        "[outrig] image config validated ({count} mcp {})",
113        plural(count, "server", "servers")
114    );
115
116    if no_test {
117        eprintln!("[outrig] skipping live mcp test (--no-test)");
118        return Ok(());
119    }
120
121    container.bootstrap_user().await?;
122    for (name, spec) in &mcp {
123        let client =
124            McpClient::connect_via_podman_exec(container, spec, name, log_dir, &BTreeMap::new())
125                .await?;
126        let tools = client.list_tools().await?.len();
127        eprintln!(
128            "[outrig] mcp {name}: initialized ({tools} {})",
129            plural(tools, "tool", "tools")
130        );
131        client.shutdown().await?;
132    }
133    Ok(())
134}
135
136#[cfg(test)]
137mod tests {
138    use super::*;
139
140    #[test]
141    fn load_errors_when_image_toml_missing() {
142        let tmp = tempfile::tempdir().unwrap();
143        let err = load_project_image_toml(tmp.path()).unwrap_err();
144        assert!(err.to_string().contains("could not read"), "{err}");
145        assert!(err.to_string().contains("image.toml"), "{err}");
146    }
147
148    #[test]
149    fn load_errors_on_malformed_toml() {
150        let tmp = tempfile::tempdir().unwrap();
151        std::fs::write(tmp.path().join("image.toml"), "[image\nref =").unwrap();
152        let err = load_project_image_toml(tmp.path()).unwrap_err();
153        assert!(err.to_string().contains("image.toml"), "{err}");
154    }
155
156    #[test]
157    fn load_errors_on_missing_image_ref() {
158        let tmp = tempfile::tempdir().unwrap();
159        std::fs::write(tmp.path().join("image.toml"), "[mcp]\nfs = [\"x\"]\n").unwrap();
160        let err = load_project_image_toml(tmp.path()).unwrap_err();
161        assert!(err.to_string().contains("image.ref is required"), "{err}");
162    }
163}