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