Skip to main content

systemprompt_cli/commands/cloud/
dockerfile.rs

1use anyhow::{bail, Result};
2use std::collections::HashSet;
3use std::path::{Path, PathBuf};
4
5use systemprompt_cloud::constants::{container, storage};
6use systemprompt_extension::ExtensionRegistry;
7use systemprompt_loader::{ConfigLoader, ExtensionLoader};
8use systemprompt_models::{CliPaths, ServicesConfig};
9
10use super::tenant::find_services_config;
11
12#[derive(Debug)]
13pub struct DockerfileBuilder<'a> {
14    project_root: &'a Path,
15    profile_name: Option<&'a str>,
16    services_config: Option<ServicesConfig>,
17}
18
19impl<'a> DockerfileBuilder<'a> {
20    pub fn new(project_root: &'a Path) -> Self {
21        let services_config = find_services_config(project_root)
22            .ok()
23            .and_then(|path| ConfigLoader::load_from_path(&path).ok());
24        Self {
25            project_root,
26            profile_name: None,
27            services_config,
28        }
29    }
30
31    pub const fn with_profile(mut self, name: &'a str) -> Self {
32        self.profile_name = Some(name);
33        self
34    }
35
36    pub fn build(&self) -> String {
37        let mcp_section = self.mcp_copy_section();
38        let env_section = self.env_section();
39        let extension_dirs = Self::extension_storage_dirs();
40        let extension_assets_section = self.extension_asset_copy_section();
41
42        format!(
43            r#"# systemprompt.io Application Dockerfile
44# Built by: systemprompt cloud profile create
45# Used by: systemprompt cloud deploy
46
47FROM debian:bookworm-slim
48
49# Install runtime dependencies
50RUN apt-get update && apt-get install -y \
51    ca-certificates \
52    curl \
53    libssl3 \
54    libpq5 \
55    lsof \
56    && rm -rf /var/lib/apt/lists/*
57
58RUN useradd -m -u 1000 app
59WORKDIR {app}
60
61RUN mkdir -p {bin} {logs} {storage}/{images} {storage}/{generated} {storage}/{logos} {storage}/{audio} {storage}/{video} {storage}/{documents} {storage}/{uploads} {web}{extension_dirs}
62
63# Copy pre-built binaries
64COPY target/release/systemprompt {bin}/
65{mcp_section}
66# Copy storage assets (images, etc.)
67COPY storage {storage}
68
69# Copy web dist (generated HTML, CSS, JS)
70COPY web/dist {web_dist}
71{extension_assets_section}
72# Copy services configuration
73COPY services {services_path}
74
75# Copy profiles
76COPY .systemprompt/profiles {profiles}
77
78RUN chmod +x {bin}/* && chown -R app:app {app}
79
80USER app
81EXPOSE 8080
82
83# Environment configuration
84{env_section}
85
86HEALTHCHECK --interval=30s --timeout=10s --start-period=10s --retries=3 \
87    CMD curl -f http://localhost:8080/api/v1/health || exit 1
88
89CMD ["{bin}/systemprompt", "{cmd_infra}", "{cmd_services}", "{cmd_serve}", "--foreground"]
90"#,
91            app = container::APP,
92            bin = container::BIN,
93            logs = container::LOGS,
94            storage = container::STORAGE,
95            web = container::WEB,
96            web_dist = container::WEB_DIST,
97            services_path = container::SERVICES,
98            profiles = container::PROFILES,
99            images = storage::IMAGES,
100            generated = storage::GENERATED,
101            logos = storage::LOGOS,
102            audio = storage::AUDIO,
103            video = storage::VIDEO,
104            documents = storage::DOCUMENTS,
105            uploads = storage::UPLOADS,
106            extension_dirs = extension_dirs,
107            mcp_section = mcp_section,
108            env_section = env_section,
109            extension_assets_section = extension_assets_section,
110            cmd_infra = CliPaths::INFRA,
111            cmd_services = CliPaths::SERVICES,
112            cmd_serve = CliPaths::SERVE,
113        )
114    }
115
116    fn extension_storage_dirs() -> String {
117        let registry = ExtensionRegistry::discover();
118        let paths = registry.all_required_storage_paths();
119        if paths.is_empty() {
120            return String::new();
121        }
122
123        let mut result = String::new();
124        for path in paths {
125            result.push(' ');
126            result.push_str(container::STORAGE);
127            result.push('/');
128            result.push_str(path);
129        }
130        result
131    }
132
133    fn extension_asset_copy_section(&self) -> String {
134        let discovered = ExtensionLoader::discover(self.project_root);
135
136        if discovered.is_empty() {
137            return String::new();
138        }
139
140        let ext_dirs: HashSet<PathBuf> = discovered
141            .iter()
142            .filter_map(|ext| ext.path.strip_prefix(self.project_root).ok())
143            .map(Path::to_path_buf)
144            .collect();
145
146        if ext_dirs.is_empty() {
147            return String::new();
148        }
149
150        let mut sorted_dirs: Vec<_> = ext_dirs.into_iter().collect();
151        sorted_dirs.sort();
152
153        let copy_lines: Vec<_> = sorted_dirs
154            .iter()
155            .map(|dir| {
156                format!(
157                    "COPY {} {}/{}",
158                    dir.display(),
159                    container::APP,
160                    dir.display()
161                )
162            })
163            .collect();
164
165        format!("\n# Copy extension assets\n{}\n", copy_lines.join("\n"))
166    }
167
168    fn mcp_copy_section(&self) -> String {
169        let binaries = self.services_config.as_ref().map_or_else(
170            || ExtensionLoader::get_mcp_binary_names(self.project_root),
171            |config| ExtensionLoader::get_production_mcp_binary_names(self.project_root, config),
172        );
173
174        if binaries.is_empty() {
175            return String::new();
176        }
177
178        let lines: Vec<String> = binaries
179            .iter()
180            .map(|bin| format!("COPY target/release/{} {}/", bin, container::BIN))
181            .collect();
182
183        format!("\n# Copy MCP server binaries\n{}\n", lines.join("\n"))
184    }
185
186    fn env_section(&self) -> String {
187        let profile_env = self.profile_name.map_or_else(String::new, |name| {
188            format!(
189                "    SYSTEMPROMPT_PROFILE={}/{}/profile.yaml \\",
190                container::PROFILES,
191                name
192            )
193        });
194
195        if profile_env.is_empty() {
196            format!(
197                r#"ENV HOST=0.0.0.0 \
198    PORT=8080 \
199    RUST_LOG=info \
200    PATH="{}:$PATH" \
201    SYSTEMPROMPT_SERVICES_PATH={} \
202    SYSTEMPROMPT_TEMPLATES_PATH={} \
203    SYSTEMPROMPT_ASSETS_PATH={}"#,
204                container::BIN,
205                container::SERVICES,
206                container::TEMPLATES,
207                container::ASSETS
208            )
209        } else {
210            format!(
211                r#"ENV HOST=0.0.0.0 \
212    PORT=8080 \
213    RUST_LOG=info \
214    PATH="{}:$PATH" \
215{}
216    SYSTEMPROMPT_SERVICES_PATH={} \
217    SYSTEMPROMPT_TEMPLATES_PATH={} \
218    SYSTEMPROMPT_ASSETS_PATH={}"#,
219                container::BIN,
220                profile_env,
221                container::SERVICES,
222                container::TEMPLATES,
223                container::ASSETS
224            )
225        }
226    }
227}
228
229pub fn generate_dockerfile_content(project_root: &Path) -> String {
230    DockerfileBuilder::new(project_root).build()
231}
232
233pub fn get_required_mcp_copy_lines(
234    project_root: &Path,
235    services_config: &ServicesConfig,
236) -> Vec<String> {
237    ExtensionLoader::get_production_mcp_binary_names(project_root, services_config)
238        .iter()
239        .map(|bin| format!("COPY target/release/{} {}/", bin, container::BIN))
240        .collect()
241}
242
243fn extract_mcp_binary_names_from_dockerfile(dockerfile_content: &str) -> Vec<String> {
244    dockerfile_content
245        .lines()
246        .filter_map(|line| {
247            let trimmed = line.trim();
248            if !trimmed.starts_with("COPY target/release/systemprompt-") {
249                return None;
250            }
251            let after_copy = trimmed.strip_prefix("COPY target/release/")?;
252            let binary_name = after_copy.split_whitespace().next()?;
253            if binary_name.starts_with("systemprompt-") && binary_name != "systemprompt-*" {
254                Some(binary_name.to_string())
255            } else {
256                None
257            }
258        })
259        .collect()
260}
261
262pub fn validate_dockerfile_has_mcp_binaries(
263    dockerfile_content: &str,
264    project_root: &Path,
265    services_config: &ServicesConfig,
266) -> Vec<String> {
267    let has_wildcard = dockerfile_content.contains("target/release/systemprompt-*");
268    if has_wildcard {
269        return Vec::new();
270    }
271
272    ExtensionLoader::get_production_mcp_binary_names(project_root, services_config)
273        .into_iter()
274        .filter(|binary| {
275            let expected_pattern = format!("target/release/{}", binary);
276            !dockerfile_content.contains(&expected_pattern)
277        })
278        .collect()
279}
280
281pub fn validate_dockerfile_has_no_stale_binaries(
282    dockerfile_content: &str,
283    project_root: &Path,
284    services_config: &ServicesConfig,
285) -> Vec<String> {
286    let has_wildcard = dockerfile_content.contains("target/release/systemprompt-*");
287    if has_wildcard {
288        return Vec::new();
289    }
290
291    let dockerfile_binaries = extract_mcp_binary_names_from_dockerfile(dockerfile_content);
292    let current_binaries: HashSet<String> =
293        ExtensionLoader::get_production_mcp_binary_names(project_root, services_config)
294            .into_iter()
295            .collect();
296
297    dockerfile_binaries
298        .into_iter()
299        .filter(|binary| !current_binaries.contains(binary))
300        .collect()
301}
302
303pub fn print_dockerfile_suggestion(project_root: &Path) {
304    systemprompt_logging::CliService::info(&generate_dockerfile_content(project_root));
305}
306
307pub fn validate_profile_dockerfile(
308    dockerfile_path: &Path,
309    project_root: &Path,
310    services_config: &ServicesConfig,
311) -> Result<()> {
312    if !dockerfile_path.exists() {
313        bail!(
314            "Dockerfile not found at {}\n\nCreate a profile first with: systemprompt cloud \
315             profile create",
316            dockerfile_path.display()
317        );
318    }
319
320    let content = std::fs::read_to_string(dockerfile_path)?;
321    let missing = validate_dockerfile_has_mcp_binaries(&content, project_root, services_config);
322    let stale = validate_dockerfile_has_no_stale_binaries(&content, project_root, services_config);
323
324    match (missing.is_empty(), stale.is_empty()) {
325        (true, true) => Ok(()),
326        (false, true) => {
327            bail!(
328                "Dockerfile at {} is missing COPY commands for MCP binaries:\n\n{}\n\nAdd these \
329                 lines:\n\n{}",
330                dockerfile_path.display(),
331                missing.join(", "),
332                get_required_mcp_copy_lines(project_root, services_config).join("\n")
333            );
334        },
335        (true, false) => {
336            bail!(
337                "Dockerfile at {} has COPY commands for dev-only or removed \
338                 binaries:\n\n{}\n\nRemove these lines or regenerate with: systemprompt cloud \
339                 profile create",
340                dockerfile_path.display(),
341                stale.join(", ")
342            );
343        },
344        (false, false) => {
345            bail!(
346                "Dockerfile at {} has issues:\n\nMissing binaries: {}\nDev-only/stale binaries: \
347                 {}\n\nRegenerate with: systemprompt cloud profile create",
348                dockerfile_path.display(),
349                missing.join(", "),
350                stale.join(", ")
351            );
352        },
353    }
354}