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