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