systemprompt_cloud/deploy/
validation.rs1use 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}