systemprompt_cli/commands/cloud/dockerfile/
validation.rs1use anyhow::{Result, bail};
2use std::collections::HashSet;
3use std::path::Path;
4
5use systemprompt_cloud::constants::container;
6use systemprompt_loader::ExtensionLoader;
7use systemprompt_models::ServicesConfig;
8
9pub fn get_required_mcp_copy_lines(
10 project_root: &Path,
11 services_config: &ServicesConfig,
12) -> Vec<String> {
13 ExtensionLoader::get_production_mcp_binary_names(project_root, services_config)
14 .iter()
15 .map(|bin| format!("COPY target/release/{} {}/", bin, container::BIN))
16 .collect()
17}
18
19fn extract_mcp_binary_names_from_dockerfile(dockerfile_content: &str) -> Vec<String> {
20 dockerfile_content
21 .lines()
22 .filter_map(|line| {
23 let trimmed = line.trim();
24 if !trimmed.starts_with("COPY target/release/systemprompt-") {
25 return None;
26 }
27 let after_copy = trimmed.strip_prefix("COPY target/release/")?;
28 let binary_name = after_copy.split_whitespace().next()?;
29 if binary_name.starts_with("systemprompt-") && binary_name != "systemprompt-*" {
30 Some(binary_name.to_string())
31 } else {
32 None
33 }
34 })
35 .collect()
36}
37
38pub fn validate_dockerfile_has_mcp_binaries(
39 dockerfile_content: &str,
40 project_root: &Path,
41 services_config: &ServicesConfig,
42) -> Vec<String> {
43 let has_wildcard = dockerfile_content.contains("target/release/systemprompt-*");
44 if has_wildcard {
45 return Vec::new();
46 }
47
48 ExtensionLoader::get_production_mcp_binary_names(project_root, services_config)
49 .into_iter()
50 .filter(|binary| {
51 let expected_pattern = format!("target/release/{}", binary);
52 !dockerfile_content.contains(&expected_pattern)
53 })
54 .collect()
55}
56
57pub fn validate_dockerfile_has_no_stale_binaries(
58 dockerfile_content: &str,
59 project_root: &Path,
60 services_config: &ServicesConfig,
61) -> Vec<String> {
62 let has_wildcard = dockerfile_content.contains("target/release/systemprompt-*");
63 if has_wildcard {
64 return Vec::new();
65 }
66
67 let dockerfile_binaries = extract_mcp_binary_names_from_dockerfile(dockerfile_content);
68 let current_binaries: HashSet<String> =
69 ExtensionLoader::get_production_mcp_binary_names(project_root, services_config)
70 .into_iter()
71 .collect();
72
73 dockerfile_binaries
74 .into_iter()
75 .filter(|binary| !current_binaries.contains(binary))
76 .collect()
77}
78
79pub fn validate_profile_dockerfile(
80 dockerfile_path: &Path,
81 project_root: &Path,
82 services_config: &ServicesConfig,
83) -> Result<()> {
84 if !dockerfile_path.exists() {
85 bail!(
86 "Dockerfile not found at {}\n\nCreate a profile first with: systemprompt cloud \
87 profile create",
88 dockerfile_path.display()
89 );
90 }
91
92 let content = std::fs::read_to_string(dockerfile_path)?;
93 let missing = validate_dockerfile_has_mcp_binaries(&content, project_root, services_config);
94 let stale = validate_dockerfile_has_no_stale_binaries(&content, project_root, services_config);
95
96 match (missing.is_empty(), stale.is_empty()) {
97 (true, true) => Ok(()),
98 (false, true) => {
99 bail!(
100 "Dockerfile at {} is missing COPY commands for MCP binaries:\n\n{}\n\nAdd these \
101 lines:\n\n{}",
102 dockerfile_path.display(),
103 missing.join(", "),
104 get_required_mcp_copy_lines(project_root, services_config).join("\n")
105 );
106 },
107 (true, false) => {
108 bail!(
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 },
116 (false, false) => {
117 bail!(
118 "Dockerfile at {} has issues:\n\nMissing binaries: {}\nDev-only/stale binaries: \
119 {}\n\nRegenerate with: systemprompt cloud profile create",
120 dockerfile_path.display(),
121 missing.join(", "),
122 stale.join(", ")
123 );
124 },
125 }
126}