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