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