Skip to main content

systemprompt_cloud/deploy/
dockerfile.rs

1//! Renders the deployment Dockerfile from discovered extensions and config.
2//!
3//! Copyright (c) systemprompt.io — Business Source License 1.1.
4//! See <https://systemprompt.io> for licensing details.
5
6use std::collections::HashSet;
7use std::path::{Path, PathBuf};
8
9use systemprompt_extension::ExtensionRegistry;
10use systemprompt_loader::{ConfigLoader, ExtensionLoader};
11use systemprompt_models::{CliPaths, ServicesConfig};
12
13use super::find_services_config;
14use crate::constants::{container, storage};
15
16#[derive(Debug)]
17pub struct DockerfileBuilder<'a> {
18    project_root: &'a Path,
19    profile_name: Option<&'a str>,
20    services_config: Option<ServicesConfig>,
21}
22
23impl<'a> DockerfileBuilder<'a> {
24    pub fn new(project_root: &'a Path) -> Self {
25        let services_config = find_services_config(project_root)
26            .map_err(|e| {
27                tracing::debug!(error = %e, "No services config found for dockerfile generation");
28                e
29            })
30            .ok()
31            .and_then(|path| {
32                ConfigLoader::load_from_path(&path)
33                    .map_err(|e| {
34                        tracing::warn!(error = %e, "Failed to load services config");
35                        e
36                    })
37                    .ok()
38            });
39        Self {
40            project_root,
41            profile_name: None,
42            services_config,
43        }
44    }
45
46    #[must_use]
47    pub const fn with_profile(mut self, name: &'a str) -> Self {
48        self.profile_name = Some(name);
49        self
50    }
51
52    #[must_use]
53    pub fn build(&self) -> String {
54        let mcp_section = self.mcp_copy_section();
55        let env_section = self.env_section();
56        let extension_dirs = Self::extension_storage_dirs();
57        let extension_assets_section = self.extension_asset_copy_section();
58
59        format!(
60            r#"# systemprompt.io Application Dockerfile
61# Built by: systemprompt cloud profile create
62# Used by: systemprompt cloud deploy
63
64FROM debian:bookworm-slim
65
66# Install runtime dependencies
67RUN apt-get update && apt-get install -y \
68    ca-certificates \
69    curl \
70    libssl3 \
71    libpq5 \
72    lsof \
73    && rm -rf /var/lib/apt/lists/*
74
75RUN useradd -m -u 1000 app
76WORKDIR {app}
77
78RUN mkdir -p {bin} {logs} {storage}/{images} {storage}/{generated} {storage}/{logos} {storage}/{audio} {storage}/{video} {storage}/{documents} {storage}/{uploads} {web}{extension_dirs}
79
80# Copy pre-built binaries
81COPY target/release/systemprompt {bin}/
82{mcp_section}
83# Copy storage assets (images, etc.)
84COPY storage {storage}
85
86# Copy web dist (generated HTML, CSS, JS)
87COPY web/dist {web_dist}
88{extension_assets_section}
89# Copy services configuration
90COPY services {services_path}
91
92# Copy profiles
93COPY .systemprompt/profiles {profiles}
94RUN chmod +x {bin}/* && chown -R app:app {app}
95
96USER app
97EXPOSE 8080
98
99# Environment configuration
100{env_section}
101
102HEALTHCHECK --interval=30s --timeout=10s --start-period=10s --retries=3 \
103    CMD curl -f http://localhost:8080/api/v1/health || exit 1
104
105CMD ["{bin}/systemprompt", "{cmd_infra}", "{cmd_services}", "{cmd_serve}", "--foreground"]
106"#,
107            app = container::APP,
108            bin = container::BIN,
109            logs = container::LOGS,
110            storage = container::STORAGE,
111            web = container::WEB,
112            web_dist = container::WEB_DIST,
113            services_path = container::SERVICES,
114            profiles = container::PROFILES,
115            images = storage::IMAGES,
116            generated = storage::GENERATED,
117            logos = storage::LOGOS,
118            audio = storage::AUDIO,
119            video = storage::VIDEO,
120            documents = storage::DOCUMENTS,
121            uploads = storage::UPLOADS,
122            extension_dirs = extension_dirs,
123            mcp_section = mcp_section,
124            env_section = env_section,
125            extension_assets_section = extension_assets_section,
126            cmd_infra = CliPaths::INFRA,
127            cmd_services = CliPaths::SERVICES,
128            cmd_serve = CliPaths::SERVE,
129        )
130    }
131
132    fn extension_storage_dirs() -> String {
133        let registry = ExtensionRegistry::discover().unwrap_or_else(|e| {
134            tracing::error!(error = %e, "extension dependency cycle; using empty registry");
135            ExtensionRegistry::new()
136        });
137        let paths = registry.all_required_storage_paths();
138        if paths.is_empty() {
139            return String::new();
140        }
141
142        let mut result = String::new();
143        for path in paths {
144            result.push(' ');
145            result.push_str(container::STORAGE);
146            result.push('/');
147            result.push_str(path);
148        }
149        result
150    }
151
152    fn extension_asset_copy_section(&self) -> String {
153        let discovered = ExtensionLoader::discover(self.project_root);
154
155        if discovered.is_empty() {
156            return String::new();
157        }
158
159        let ext_dirs: HashSet<PathBuf> = discovered
160            .iter()
161            .filter_map(|ext| ext.path.strip_prefix(self.project_root).ok())
162            .map(Path::to_path_buf)
163            .collect();
164
165        if ext_dirs.is_empty() {
166            return String::new();
167        }
168
169        let mut sorted_dirs: Vec<_> = ext_dirs.into_iter().collect();
170        sorted_dirs.sort();
171
172        let copy_lines: Vec<_> = sorted_dirs
173            .iter()
174            .map(|dir| {
175                format!(
176                    "COPY {} {}/{}",
177                    dir.display(),
178                    container::APP,
179                    dir.display()
180                )
181            })
182            .collect();
183
184        format!("\n# Copy extension assets\n{}\n", copy_lines.join("\n"))
185    }
186
187    fn mcp_copy_section(&self) -> String {
188        let binaries = self.services_config.as_ref().map_or_else(
189            || ExtensionLoader::get_mcp_binary_names(self.project_root),
190            |config| ExtensionLoader::get_production_mcp_binary_names(self.project_root, config),
191        );
192
193        if binaries.is_empty() {
194            return String::new();
195        }
196
197        let lines: Vec<String> = binaries
198            .iter()
199            .map(|bin| format!("COPY target/release/{} {}/", bin, container::BIN))
200            .collect();
201
202        format!("\n# Copy MCP server binaries\n{}\n", lines.join("\n"))
203    }
204
205    fn env_section(&self) -> String {
206        let profile_env = self.profile_name.map_or_else(String::new, |name| {
207            format!(
208                "    SYSTEMPROMPT_PROFILE={}/{}/profile.yaml \\",
209                container::PROFILES,
210                name
211            )
212        });
213
214        if profile_env.is_empty() {
215            format!(
216                r#"ENV HOST=0.0.0.0 \
217    PORT=8080 \
218    RUST_LOG=info \
219    PATH="{}:$PATH" \
220    SYSTEMPROMPT_SERVICES_PATH={} \
221    SYSTEMPROMPT_TEMPLATES_PATH={} \
222    SYSTEMPROMPT_ASSETS_PATH={}"#,
223                container::BIN,
224                container::SERVICES,
225                container::TEMPLATES,
226                container::ASSETS
227            )
228        } else {
229            format!(
230                r#"ENV HOST=0.0.0.0 \
231    PORT=8080 \
232    RUST_LOG=info \
233    PATH="{}:$PATH" \
234{}
235    SYSTEMPROMPT_SERVICES_PATH={} \
236    SYSTEMPROMPT_TEMPLATES_PATH={} \
237    SYSTEMPROMPT_ASSETS_PATH={}"#,
238                container::BIN,
239                profile_env,
240                container::SERVICES,
241                container::TEMPLATES,
242                container::ASSETS
243            )
244        }
245    }
246}