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:trixie-slim
65
66# Install runtime dependencies
67RUN apt-get update && apt-get install -y \
68    ca-certificates \
69    curl \
70    libssl3t64 \
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} {services_cache}{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            services_cache = container::SERVICES_CACHE,
115            profiles = container::PROFILES,
116            images = storage::IMAGES,
117            generated = storage::GENERATED,
118            logos = storage::LOGOS,
119            audio = storage::AUDIO,
120            video = storage::VIDEO,
121            documents = storage::DOCUMENTS,
122            uploads = storage::UPLOADS,
123            extension_dirs = extension_dirs,
124            mcp_section = mcp_section,
125            env_section = env_section,
126            extension_assets_section = extension_assets_section,
127            cmd_infra = CliPaths::INFRA,
128            cmd_services = CliPaths::SERVICES,
129            cmd_serve = CliPaths::SERVE,
130        )
131    }
132
133    fn extension_storage_dirs() -> String {
134        let registry = ExtensionRegistry::discover().unwrap_or_else(|e| {
135            tracing::error!(error = %e, "extension dependency cycle; using empty registry");
136            ExtensionRegistry::new()
137        });
138        let paths = registry.all_required_storage_paths();
139        if paths.is_empty() {
140            return String::new();
141        }
142
143        let mut result = String::new();
144        for path in paths {
145            result.push(' ');
146            result.push_str(container::STORAGE);
147            result.push('/');
148            result.push_str(path);
149        }
150        result
151    }
152
153    fn extension_asset_copy_section(&self) -> String {
154        let discovered = ExtensionLoader::discover(self.project_root);
155
156        if discovered.is_empty() {
157            return String::new();
158        }
159
160        let ext_dirs: HashSet<PathBuf> = discovered
161            .iter()
162            .filter_map(|ext| ext.path.strip_prefix(self.project_root).ok())
163            .map(Path::to_path_buf)
164            .collect();
165
166        if ext_dirs.is_empty() {
167            return String::new();
168        }
169
170        let mut sorted_dirs: Vec<_> = ext_dirs.into_iter().collect();
171        sorted_dirs.sort();
172
173        let copy_lines: Vec<_> = sorted_dirs
174            .iter()
175            .map(|dir| {
176                format!(
177                    "COPY {} {}/{}",
178                    dir.display(),
179                    container::APP,
180                    dir.display()
181                )
182            })
183            .collect();
184
185        format!("\n# Copy extension assets\n{}\n", copy_lines.join("\n"))
186    }
187
188    fn mcp_copy_section(&self) -> String {
189        let binaries = self.services_config.as_ref().map_or_else(
190            || ExtensionLoader::get_mcp_binary_names(self.project_root),
191            |config| ExtensionLoader::get_production_mcp_binary_names(self.project_root, config),
192        );
193
194        if binaries.is_empty() {
195            return String::new();
196        }
197
198        let lines: Vec<String> = binaries
199            .iter()
200            .map(|bin| format!("COPY target/release/{} {}/", bin, container::BIN))
201            .collect();
202
203        format!("\n# Copy MCP server binaries\n{}\n", lines.join("\n"))
204    }
205
206    fn deployment_host_env(&self) -> String {
207        format!(
208            "    {}={} \\",
209            systemprompt_models::subprocess::DEPLOYMENT_HOST_ENV,
210            self.profile_name.unwrap_or("container")
211        )
212    }
213
214    fn env_section(&self) -> String {
215        let profile_env = self.profile_name.map_or_else(String::new, |name| {
216            format!(
217                "    SYSTEMPROMPT_PROFILE={}/{}/profile.yaml \\",
218                container::PROFILES,
219                name
220            )
221        });
222
223        if profile_env.is_empty() {
224            format!(
225                r#"ENV HOST=0.0.0.0 \
226    PORT=8080 \
227    RUST_LOG=info \
228    PATH="{}:$PATH" \
229{}
230    SYSTEMPROMPT_SERVICES_PATH={} \
231    SYSTEMPROMPT_TEMPLATES_PATH={} \
232    SYSTEMPROMPT_ASSETS_PATH={}"#,
233                container::BIN,
234                self.deployment_host_env(),
235                container::SERVICES,
236                container::TEMPLATES,
237                container::ASSETS
238            )
239        } else {
240            format!(
241                r#"ENV HOST=0.0.0.0 \
242    PORT=8080 \
243    RUST_LOG=info \
244    PATH="{}:$PATH" \
245{}
246{}
247    SYSTEMPROMPT_SERVICES_PATH={} \
248    SYSTEMPROMPT_TEMPLATES_PATH={} \
249    SYSTEMPROMPT_ASSETS_PATH={}"#,
250                container::BIN,
251                profile_env,
252                self.deployment_host_env(),
253                container::SERVICES,
254                container::TEMPLATES,
255                container::ASSETS
256            )
257        }
258    }
259}