Skip to main content

systemprompt_cloud/deploy/
validation.rs

1//! Validation of a profile's Dockerfile against the discovered MCP binaries.
2
3use std::collections::HashSet;
4use std::path::Path;
5
6use systemprompt_loader::ExtensionLoader;
7use systemprompt_models::ServicesConfig;
8
9use crate::constants::container;
10use crate::error::{CloudError, CloudResult};
11
12pub fn get_required_mcp_copy_lines(
13    project_root: &Path,
14    services_config: &ServicesConfig,
15) -> Vec<String> {
16    ExtensionLoader::get_production_mcp_binary_names(project_root, services_config)
17        .iter()
18        .map(|bin| format!("COPY target/release/{} {}/", bin, container::BIN))
19        .collect()
20}
21
22fn extract_mcp_binary_names_from_dockerfile(dockerfile_content: &str) -> Vec<String> {
23    dockerfile_content
24        .lines()
25        .filter_map(|line| {
26            let trimmed = line.trim();
27            if !trimmed.starts_with("COPY target/release/systemprompt-") {
28                return None;
29            }
30            let after_copy = trimmed.strip_prefix("COPY target/release/")?;
31            let binary_name = after_copy.split_whitespace().next()?;
32            if binary_name.starts_with("systemprompt-") && binary_name != "systemprompt-*" {
33                Some(binary_name.to_owned())
34            } else {
35                None
36            }
37        })
38        .collect()
39}
40
41pub fn validate_dockerfile_has_mcp_binaries(
42    dockerfile_content: &str,
43    project_root: &Path,
44    services_config: &ServicesConfig,
45) -> Vec<String> {
46    let has_wildcard = dockerfile_content.contains("target/release/systemprompt-*");
47    if has_wildcard {
48        return Vec::new();
49    }
50
51    ExtensionLoader::get_production_mcp_binary_names(project_root, services_config)
52        .into_iter()
53        .filter(|binary| {
54            let expected_pattern = format!("target/release/{}", binary);
55            !dockerfile_content.contains(&expected_pattern)
56        })
57        .collect()
58}
59
60pub fn validate_dockerfile_has_no_stale_binaries(
61    dockerfile_content: &str,
62    project_root: &Path,
63    services_config: &ServicesConfig,
64) -> Vec<String> {
65    let has_wildcard = dockerfile_content.contains("target/release/systemprompt-*");
66    if has_wildcard {
67        return Vec::new();
68    }
69
70    let dockerfile_binaries = extract_mcp_binary_names_from_dockerfile(dockerfile_content);
71    let current_binaries: HashSet<String> =
72        ExtensionLoader::get_production_mcp_binary_names(project_root, services_config)
73            .into_iter()
74            .collect();
75
76    dockerfile_binaries
77        .into_iter()
78        .filter(|binary| !current_binaries.contains(binary))
79        .collect()
80}
81
82pub fn validate_profile_dockerfile(
83    dockerfile_path: &Path,
84    project_root: &Path,
85    services_config: &ServicesConfig,
86) -> CloudResult<()> {
87    if !dockerfile_path.exists() {
88        return Err(CloudError::dockerfile(format!(
89            "Dockerfile not found at {}\n\nCreate a profile first with: systemprompt cloud \
90             profile create",
91            dockerfile_path.display()
92        )));
93    }
94
95    let content = std::fs::read_to_string(dockerfile_path)?;
96    let missing = validate_dockerfile_has_mcp_binaries(&content, project_root, services_config);
97    let stale = validate_dockerfile_has_no_stale_binaries(&content, project_root, services_config);
98
99    match (missing.is_empty(), stale.is_empty()) {
100        (true, true) => Ok(()),
101        (false, true) => Err(CloudError::dockerfile(format!(
102            "Dockerfile at {} is missing COPY commands for MCP binaries:\n\n{}\n\nAdd these \
103             lines:\n\n{}",
104            dockerfile_path.display(),
105            missing.join(", "),
106            get_required_mcp_copy_lines(project_root, services_config).join("\n")
107        ))),
108        (true, false) => Err(CloudError::dockerfile(format!(
109            "Dockerfile at {} has COPY commands for dev-only or removed \
110             binaries:\n\n{}\n\nRemove these lines or regenerate with: systemprompt cloud \
111             profile create",
112            dockerfile_path.display(),
113            stale.join(", ")
114        ))),
115        (false, false) => Err(CloudError::dockerfile(format!(
116            "Dockerfile at {} has issues:\n\nMissing binaries: {}\nDev-only/stale binaries: \
117             {}\n\nRegenerate with: systemprompt cloud profile create",
118            dockerfile_path.display(),
119            missing.join(", "),
120            stale.join(", ")
121        ))),
122    }
123}