Skip to main content

systemprompt_cloud/deploy/
validation.rs

1//! Validation of a profile's Dockerfile against the discovered MCP binaries.
2//!
3//! A `COPY target/release/systemprompt-*` line is stale unless it names a
4//! production MCP binary or a binary an enabled CLI extension manifest
5//! declares; CLI binaries are never required, only accepted.
6//!
7//! Copyright (c) systemprompt.io — Business Source License 1.1.
8//! See <https://systemprompt.io> for licensing details.
9
10use std::collections::HashSet;
11use std::path::Path;
12
13use systemprompt_loader::ExtensionLoader;
14use systemprompt_manifest::ServicesConfig;
15
16use crate::constants::container;
17use crate::error::{CloudError, CloudResult};
18
19pub fn get_required_mcp_copy_lines(
20    project_root: &Path,
21    services_config: &ServicesConfig,
22) -> Vec<String> {
23    ExtensionLoader::get_production_mcp_binary_names(project_root, services_config)
24        .iter()
25        .map(|bin| format!("COPY target/release/{} {}/", bin, container::BIN))
26        .collect()
27}
28
29fn extract_mcp_binary_names_from_dockerfile(dockerfile_content: &str) -> Vec<String> {
30    dockerfile_content
31        .lines()
32        .filter_map(|line| {
33            let trimmed = line.trim();
34            if !trimmed.starts_with("COPY target/release/systemprompt-") {
35                return None;
36            }
37            let after_copy = trimmed.strip_prefix("COPY target/release/")?;
38            let binary_name = after_copy.split_whitespace().next()?;
39            if binary_name.starts_with("systemprompt-") && binary_name != "systemprompt-*" {
40                Some(binary_name.to_owned())
41            } else {
42                None
43            }
44        })
45        .collect()
46}
47
48pub fn validate_dockerfile_has_mcp_binaries(
49    dockerfile_content: &str,
50    project_root: &Path,
51    services_config: &ServicesConfig,
52) -> Vec<String> {
53    let has_wildcard = dockerfile_content.contains("target/release/systemprompt-*");
54    if has_wildcard {
55        return Vec::new();
56    }
57
58    ExtensionLoader::get_production_mcp_binary_names(project_root, services_config)
59        .into_iter()
60        .filter(|binary| {
61            let expected_pattern = format!("target/release/{}", binary);
62            !dockerfile_content.contains(&expected_pattern)
63        })
64        .collect()
65}
66
67pub fn validate_dockerfile_has_no_stale_binaries(
68    dockerfile_content: &str,
69    project_root: &Path,
70    services_config: &ServicesConfig,
71) -> Vec<String> {
72    let has_wildcard = dockerfile_content.contains("target/release/systemprompt-*");
73    if has_wildcard {
74        return Vec::new();
75    }
76
77    let dockerfile_binaries = extract_mcp_binary_names_from_dockerfile(dockerfile_content);
78    let cli_binaries = ExtensionLoader::get_enabled_cli_extensions(project_root)
79        .into_iter()
80        .filter_map(|e| e.binary_name().map(String::from));
81    let current_binaries: HashSet<String> =
82        ExtensionLoader::get_production_mcp_binary_names(project_root, services_config)
83            .into_iter()
84            .chain(cli_binaries)
85            .collect();
86
87    dockerfile_binaries
88        .into_iter()
89        .filter(|binary| !current_binaries.contains(binary))
90        .collect()
91}
92
93pub fn validate_profile_dockerfile(
94    dockerfile_path: &Path,
95    project_root: &Path,
96    services_config: &ServicesConfig,
97) -> CloudResult<()> {
98    if !dockerfile_path.exists() {
99        return Err(CloudError::dockerfile(format!(
100            "Dockerfile not found at {}\n\nCreate a profile first with: systemprompt cloud \
101             profile create",
102            dockerfile_path.display()
103        )));
104    }
105
106    let content = std::fs::read_to_string(dockerfile_path)?;
107    let missing = validate_dockerfile_has_mcp_binaries(&content, project_root, services_config);
108    let stale = validate_dockerfile_has_no_stale_binaries(&content, project_root, services_config);
109
110    match (missing.is_empty(), stale.is_empty()) {
111        (true, true) => Ok(()),
112        (false, true) => Err(CloudError::dockerfile(format!(
113            "Dockerfile at {} is missing COPY commands for MCP binaries:\n\n{}\n\nThis file is \
114             generated from the MCP servers declared in services/, so it goes stale whenever one \
115             is added. Re-render it:\n\n    systemprompt cloud dockerfile --profile \
116             <name>\n\nThe lines it will add:\n\n{}",
117            dockerfile_path.display(),
118            missing.join(", "),
119            get_required_mcp_copy_lines(project_root, services_config).join("\n")
120        ))),
121        (true, false) => Err(CloudError::dockerfile(format!(
122            "Dockerfile at {} has COPY commands for dev-only or removed \
123             binaries:\n\n{}\n\nRemove these lines, or re-render the generated file \
124             with:\n\n    systemprompt cloud dockerfile --profile <name>",
125            dockerfile_path.display(),
126            stale.join(", ")
127        ))),
128        (false, false) => Err(CloudError::dockerfile(format!(
129            "Dockerfile at {} has issues:\n\nMissing binaries: {}\nDev-only/stale binaries: \
130             {}\n\nRe-render the generated file with:\n\n    systemprompt cloud dockerfile \
131             --profile <name>",
132            dockerfile_path.display(),
133            missing.join(", "),
134            stale.join(", ")
135        ))),
136    }
137}