Skip to main content

systemprompt_cli/commands/cloud/dockerfile/
builder.rs

1//! Renders the deployment Dockerfile from discovered extensions and config.
2//!
3//! [`DockerfileBuilder`] composes the runtime image: storage directories,
4//! MCP binary copies, extension asset copies, and the profile-aware
5//! environment block.
6
7use std::collections::HashSet;
8use std::path::{Path, PathBuf};
9
10use systemprompt_cloud::constants::{container, storage};
11use systemprompt_extension::ExtensionRegistry;
12use systemprompt_loader::{ConfigLoader, ExtensionLoader};
13use systemprompt_models::{CliPaths, ServicesConfig};
14
15use super::super::tenant::find_services_config;
16
17#[derive(Debug)]
18pub struct DockerfileBuilder<'a> {
19    project_root: &'a Path,
20    profile_name: Option<&'a str>,
21    services_config: Option<ServicesConfig>,
22}
23
24impl<'a> DockerfileBuilder<'a> {
25    pub fn new(project_root: &'a Path) -> Self {
26        let services_config = find_services_config(project_root)
27            .map_err(|e| {
28                tracing::debug!(error = %e, "No services config found for dockerfile generation");
29                e
30            })
31            .ok()
32            .and_then(|path| {
33                ConfigLoader::load_from_path(&path)
34                    .map_err(|e| {
35                        tracing::warn!(error = %e, "Failed to load services config");
36                        e
37                    })
38                    .ok()
39            });
40        Self {
41            project_root,
42            profile_name: None,
43            services_config,
44        }
45    }
46
47    pub const fn with_profile(mut self, name: &'a str) -> Self {
48        self.profile_name = Some(name);
49        self
50    }
51
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:bookworm-slim
64
65# Install runtime dependencies
66RUN apt-get update && apt-get install -y \
67    ca-certificates \
68    curl \
69    libssl3 \
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}/{images} {storage}/{generated} {storage}/{logos} {storage}/{audio} {storage}/{video} {storage}/{documents} {storage}/{uploads} {web}{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 8080
97
98# Environment configuration
99{env_section}
100
101HEALTHCHECK --interval=30s --timeout=10s --start-period=10s --retries=3 \
102    CMD curl -f http://localhost:8080/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            profiles = container::PROFILES,
114            images = storage::IMAGES,
115            generated = storage::GENERATED,
116            logos = storage::LOGOS,
117            audio = storage::AUDIO,
118            video = storage::VIDEO,
119            documents = storage::DOCUMENTS,
120            uploads = storage::UPLOADS,
121            extension_dirs = extension_dirs,
122            mcp_section = mcp_section,
123            env_section = env_section,
124            extension_assets_section = extension_assets_section,
125            cmd_infra = CliPaths::INFRA,
126            cmd_services = CliPaths::SERVICES,
127            cmd_serve = CliPaths::SERVE,
128        )
129    }
130
131    fn extension_storage_dirs() -> String {
132        let registry = ExtensionRegistry::discover().unwrap_or_else(|e| {
133            tracing::error!(error = %e, "extension dependency cycle; using empty registry");
134            ExtensionRegistry::new()
135        });
136        let paths = registry.all_required_storage_paths();
137        if paths.is_empty() {
138            return String::new();
139        }
140
141        let mut result = String::new();
142        for path in paths {
143            result.push(' ');
144            result.push_str(container::STORAGE);
145            result.push('/');
146            result.push_str(path);
147        }
148        result
149    }
150
151    fn extension_asset_copy_section(&self) -> String {
152        let discovered = ExtensionLoader::discover(self.project_root);
153
154        if discovered.is_empty() {
155            return String::new();
156        }
157
158        let ext_dirs: HashSet<PathBuf> = discovered
159            .iter()
160            .filter_map(|ext| ext.path.strip_prefix(self.project_root).ok())
161            .map(Path::to_path_buf)
162            .collect();
163
164        if ext_dirs.is_empty() {
165            return String::new();
166        }
167
168        let mut sorted_dirs: Vec<_> = ext_dirs.into_iter().collect();
169        sorted_dirs.sort();
170
171        let copy_lines: Vec<_> = sorted_dirs
172            .iter()
173            .map(|dir| {
174                format!(
175                    "COPY {} {}/{}",
176                    dir.display(),
177                    container::APP,
178                    dir.display()
179                )
180            })
181            .collect();
182
183        format!("\n# Copy extension assets\n{}\n", copy_lines.join("\n"))
184    }
185
186    fn mcp_copy_section(&self) -> String {
187        let binaries = self.services_config.as_ref().map_or_else(
188            || ExtensionLoader::get_mcp_binary_names(self.project_root),
189            |config| ExtensionLoader::get_production_mcp_binary_names(self.project_root, config),
190        );
191
192        if binaries.is_empty() {
193            return String::new();
194        }
195
196        let lines: Vec<String> = binaries
197            .iter()
198            .map(|bin| format!("COPY target/release/{} {}/", bin, container::BIN))
199            .collect();
200
201        format!("\n# Copy MCP server binaries\n{}\n", lines.join("\n"))
202    }
203
204    fn env_section(&self) -> String {
205        let profile_env = self.profile_name.map_or_else(String::new, |name| {
206            format!(
207                "    SYSTEMPROMPT_PROFILE={}/{}/profile.yaml \\",
208                container::PROFILES,
209                name
210            )
211        });
212
213        if profile_env.is_empty() {
214            format!(
215                r#"ENV HOST=0.0.0.0 \
216    PORT=8080 \
217    RUST_LOG=info \
218    PATH="{}:$PATH" \
219    SYSTEMPROMPT_SERVICES_PATH={} \
220    SYSTEMPROMPT_TEMPLATES_PATH={} \
221    SYSTEMPROMPT_ASSETS_PATH={}"#,
222                container::BIN,
223                container::SERVICES,
224                container::TEMPLATES,
225                container::ASSETS
226            )
227        } else {
228            format!(
229                r#"ENV HOST=0.0.0.0 \
230    PORT=8080 \
231    RUST_LOG=info \
232    PATH="{}:$PATH" \
233{}
234    SYSTEMPROMPT_SERVICES_PATH={} \
235    SYSTEMPROMPT_TEMPLATES_PATH={} \
236    SYSTEMPROMPT_ASSETS_PATH={}"#,
237                container::BIN,
238                profile_env,
239                container::SERVICES,
240                container::TEMPLATES,
241                container::ASSETS
242            )
243        }
244    }
245}