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\nAdd these \
106             lines:\n\n{}",
107            dockerfile_path.display(),
108            missing.join(", "),
109            get_required_mcp_copy_lines(project_root, services_config).join("\n")
110        ))),
111        (true, false) => Err(CloudError::dockerfile(format!(
112            "Dockerfile at {} has COPY commands for dev-only or removed \
113             binaries:\n\n{}\n\nRemove these lines or regenerate with: systemprompt cloud \
114             profile create",
115            dockerfile_path.display(),
116            stale.join(", ")
117        ))),
118        (false, false) => Err(CloudError::dockerfile(format!(
119            "Dockerfile at {} has issues:\n\nMissing binaries: {}\nDev-only/stale binaries: \
120             {}\n\nRegenerate with: systemprompt cloud profile create",
121            dockerfile_path.display(),
122            missing.join(", "),
123            stale.join(", ")
124        ))),
125    }
126}