systemprompt_cloud/deploy/
dockerfile.rs1use 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}