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