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