Skip to main content

systemprompt_cli/commands/cloud/
dockerfile.rs

1use anyhow::{bail, Result};
2use std::collections::HashSet;
3use std::path::{Path, PathBuf};
4
5use systemprompt_cloud::constants::{container, storage};
6use systemprompt_extension::ExtensionRegistry;
7use systemprompt_loader::{ConfigLoader, ExtensionLoader};
8use systemprompt_models::{CliPaths, ServicesConfig};
9
10use super::tenant::find_services_config;
11
12#[derive(Debug)]
13pub struct DockerfileBuilder<'a> {
14    project_root: &'a Path,
15    profile_name: Option<&'a str>,
16    services_config: Option<ServicesConfig>,
17}
18
19impl<'a> DockerfileBuilder<'a> {
20    pub fn new(project_root: &'a Path) -> Self {
21        let services_config = find_services_config(project_root)
22            .ok()
23            .and_then(|path| ConfigLoader::load_from_path(&path).ok());
24        Self {
25            project_root,
26            profile_name: None,
27            services_config,
28        }
29    }
30
31    pub const fn with_profile(mut self, name: &'a str) -> Self {
32        self.profile_name = Some(name);
33        self
34    }
35
36    pub fn build(&self) -> String {
37        let mcp_section = self.mcp_copy_section();
38        let env_section = self.env_section();
39        let extension_dirs = Self::extension_storage_dirs();
40        let extension_assets_section = self.extension_asset_copy_section();
41
42        format!(
43            r#"# systemprompt.io Application Dockerfile
44# Built by: systemprompt cloud profile create
45# Used by: systemprompt cloud deploy
46
47FROM debian:bookworm-slim
48
49# Install runtime dependencies
50RUN apt-get update && apt-get install -y \
51    ca-certificates \
52    curl \
53    libssl3 \
54    libpq5 \
55    lsof \
56    && rm -rf /var/lib/apt/lists/*
57
58RUN useradd -m -u 1000 app
59WORKDIR {app}
60
61RUN mkdir -p {bin} {logs} {storage}/{images} {storage}/{generated} {storage}/{logos} {storage}/{audio} {storage}/{video} {storage}/{documents} {storage}/{uploads}{extension_dirs}
62
63# Copy pre-built binaries
64COPY target/release/systemprompt {bin}/
65{mcp_section}
66# Copy storage assets (images, etc.)
67COPY storage {storage}
68{extension_assets_section}
69# Copy services configuration
70COPY services {services_path}
71
72# Copy profiles
73COPY .systemprompt/profiles {profiles}
74
75RUN chmod +x {bin}/* && chown -R app:app {app}
76
77USER app
78EXPOSE 8080
79
80# Environment configuration
81{env_section}
82
83HEALTHCHECK --interval=30s --timeout=10s --start-period=10s --retries=3 \
84    CMD curl -f http://localhost:8080/api/v1/health || exit 1
85
86CMD ["{bin}/systemprompt", "{cmd_infra}", "{cmd_services}", "{cmd_serve}", "--foreground"]
87"#,
88            app = container::APP,
89            bin = container::BIN,
90            logs = container::LOGS,
91            storage = container::STORAGE,
92            services_path = container::SERVICES,
93            profiles = container::PROFILES,
94            images = storage::IMAGES,
95            generated = storage::GENERATED,
96            logos = storage::LOGOS,
97            audio = storage::AUDIO,
98            video = storage::VIDEO,
99            documents = storage::DOCUMENTS,
100            uploads = storage::UPLOADS,
101            extension_dirs = extension_dirs,
102            mcp_section = mcp_section,
103            env_section = env_section,
104            extension_assets_section = extension_assets_section,
105            cmd_infra = CliPaths::INFRA,
106            cmd_services = CliPaths::SERVICES,
107            cmd_serve = CliPaths::SERVE,
108        )
109    }
110
111    fn extension_storage_dirs() -> String {
112        let registry = ExtensionRegistry::discover();
113        let paths = registry.all_required_storage_paths();
114        if paths.is_empty() {
115            return String::new();
116        }
117
118        let mut result = String::new();
119        for path in paths {
120            result.push(' ');
121            result.push_str(container::STORAGE);
122            result.push('/');
123            result.push_str(path);
124        }
125        result
126    }
127
128    fn extension_asset_copy_section(&self) -> String {
129        let discovered = ExtensionLoader::discover(self.project_root);
130
131        if discovered.is_empty() {
132            return String::new();
133        }
134
135        let ext_dirs: HashSet<PathBuf> = discovered
136            .iter()
137            .filter_map(|ext| ext.path.strip_prefix(self.project_root).ok())
138            .map(Path::to_path_buf)
139            .collect();
140
141        if ext_dirs.is_empty() {
142            return String::new();
143        }
144
145        let mut sorted_dirs: Vec<_> = ext_dirs.into_iter().collect();
146        sorted_dirs.sort();
147
148        let copy_lines: Vec<_> = sorted_dirs
149            .iter()
150            .map(|dir| {
151                format!(
152                    "COPY {} {}/{}",
153                    dir.display(),
154                    container::APP,
155                    dir.display()
156                )
157            })
158            .collect();
159
160        format!("\n# Copy extension assets\n{}\n", copy_lines.join("\n"))
161    }
162
163    fn mcp_copy_section(&self) -> String {
164        let binaries = self.services_config.as_ref().map_or_else(
165            || ExtensionLoader::get_mcp_binary_names(self.project_root),
166            |config| ExtensionLoader::get_production_mcp_binary_names(self.project_root, config),
167        );
168
169        if binaries.is_empty() {
170            return String::new();
171        }
172
173        let lines: Vec<String> = binaries
174            .iter()
175            .map(|bin| format!("COPY target/release/{} {}/", bin, container::BIN))
176            .collect();
177
178        format!("\n# Copy MCP server binaries\n{}\n", lines.join("\n"))
179    }
180
181    fn env_section(&self) -> String {
182        let profile_env = self.profile_name.map_or_else(String::new, |name| {
183            format!(
184                "    SYSTEMPROMPT_PROFILE={}/{}/profile.yaml \\",
185                container::PROFILES,
186                name
187            )
188        });
189
190        if profile_env.is_empty() {
191            format!(
192                r#"ENV HOST=0.0.0.0 \
193    PORT=8080 \
194    RUST_LOG=info \
195    PATH="{}:$PATH" \
196    SYSTEMPROMPT_SERVICES_PATH={} \
197    SYSTEMPROMPT_TEMPLATES_PATH={} \
198    SYSTEMPROMPT_ASSETS_PATH={}"#,
199                container::BIN,
200                container::SERVICES,
201                container::TEMPLATES,
202                container::ASSETS
203            )
204        } else {
205            format!(
206                r#"ENV HOST=0.0.0.0 \
207    PORT=8080 \
208    RUST_LOG=info \
209    PATH="{}:$PATH" \
210{}
211    SYSTEMPROMPT_SERVICES_PATH={} \
212    SYSTEMPROMPT_TEMPLATES_PATH={} \
213    SYSTEMPROMPT_ASSETS_PATH={}"#,
214                container::BIN,
215                profile_env,
216                container::SERVICES,
217                container::TEMPLATES,
218                container::ASSETS
219            )
220        }
221    }
222}
223
224pub fn generate_dockerfile_content(project_root: &Path) -> String {
225    DockerfileBuilder::new(project_root).build()
226}
227
228pub fn get_required_mcp_copy_lines(
229    project_root: &Path,
230    services_config: &ServicesConfig,
231) -> Vec<String> {
232    ExtensionLoader::get_production_mcp_binary_names(project_root, services_config)
233        .iter()
234        .map(|bin| format!("COPY target/release/{} {}/", bin, container::BIN))
235        .collect()
236}
237
238fn extract_mcp_binary_names_from_dockerfile(dockerfile_content: &str) -> Vec<String> {
239    dockerfile_content
240        .lines()
241        .filter_map(|line| {
242            let trimmed = line.trim();
243            if !trimmed.starts_with("COPY target/release/systemprompt-") {
244                return None;
245            }
246            let after_copy = trimmed.strip_prefix("COPY target/release/")?;
247            let binary_name = after_copy.split_whitespace().next()?;
248            if binary_name.starts_with("systemprompt-") && binary_name != "systemprompt-*" {
249                Some(binary_name.to_string())
250            } else {
251                None
252            }
253        })
254        .collect()
255}
256
257pub fn validate_dockerfile_has_mcp_binaries(
258    dockerfile_content: &str,
259    project_root: &Path,
260    services_config: &ServicesConfig,
261) -> Vec<String> {
262    let has_wildcard = dockerfile_content.contains("target/release/systemprompt-*");
263    if has_wildcard {
264        return Vec::new();
265    }
266
267    ExtensionLoader::get_production_mcp_binary_names(project_root, services_config)
268        .into_iter()
269        .filter(|binary| {
270            let expected_pattern = format!("target/release/{}", binary);
271            !dockerfile_content.contains(&expected_pattern)
272        })
273        .collect()
274}
275
276pub fn validate_dockerfile_has_no_stale_binaries(
277    dockerfile_content: &str,
278    project_root: &Path,
279    services_config: &ServicesConfig,
280) -> Vec<String> {
281    let has_wildcard = dockerfile_content.contains("target/release/systemprompt-*");
282    if has_wildcard {
283        return Vec::new();
284    }
285
286    let dockerfile_binaries = extract_mcp_binary_names_from_dockerfile(dockerfile_content);
287    let current_binaries: HashSet<String> =
288        ExtensionLoader::get_production_mcp_binary_names(project_root, services_config)
289            .into_iter()
290            .collect();
291
292    dockerfile_binaries
293        .into_iter()
294        .filter(|binary| !current_binaries.contains(binary))
295        .collect()
296}
297
298pub fn print_dockerfile_suggestion(project_root: &Path) {
299    systemprompt_logging::CliService::info(&generate_dockerfile_content(project_root));
300}
301
302pub fn validate_profile_dockerfile(
303    dockerfile_path: &Path,
304    project_root: &Path,
305    services_config: &ServicesConfig,
306) -> Result<()> {
307    if !dockerfile_path.exists() {
308        bail!(
309            "Dockerfile not found at {}\n\nCreate a profile first with: systemprompt cloud \
310             profile create",
311            dockerfile_path.display()
312        );
313    }
314
315    let content = std::fs::read_to_string(dockerfile_path)?;
316    let missing = validate_dockerfile_has_mcp_binaries(&content, project_root, services_config);
317    let stale = validate_dockerfile_has_no_stale_binaries(&content, project_root, services_config);
318
319    match (missing.is_empty(), stale.is_empty()) {
320        (true, true) => Ok(()),
321        (false, true) => {
322            bail!(
323                "Dockerfile at {} is missing COPY commands for MCP binaries:\n\n{}\n\nAdd these \
324                 lines:\n\n{}",
325                dockerfile_path.display(),
326                missing.join(", "),
327                get_required_mcp_copy_lines(project_root, services_config).join("\n")
328            );
329        },
330        (true, false) => {
331            bail!(
332                "Dockerfile at {} has COPY commands for dev-only or removed \
333                 binaries:\n\n{}\n\nRemove these lines or regenerate with: systemprompt cloud \
334                 profile create",
335                dockerfile_path.display(),
336                stale.join(", ")
337            );
338        },
339        (false, false) => {
340            bail!(
341                "Dockerfile at {} has issues:\n\nMissing binaries: {}\nDev-only/stale binaries: \
342                 {}\n\nRegenerate with: systemprompt cloud profile create",
343                dockerfile_path.display(),
344                missing.join(", "),
345                stale.join(", ")
346            );
347        },
348    }
349}