Skip to main content

systemprompt_cloud/deploy/
dockerfile.rs

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