Skip to main content

systemprompt_cloud/deploy/
validation.rs

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