Skip to main content

systemprompt_cli/commands/cloud/dockerfile/
builder.rs

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