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\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}