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().unwrap_or_else(|e| {
127            tracing::error!(error = %e, "extension dependency cycle; using empty registry");
128            ExtensionRegistry::new()
129        });
130        let paths = registry.all_required_storage_paths();
131        if paths.is_empty() {
132            return String::new();
133        }
134
135        let mut result = String::new();
136        for path in paths {
137            result.push(' ');
138            result.push_str(container::STORAGE);
139            result.push('/');
140            result.push_str(path);
141        }
142        result
143    }
144
145    fn extension_asset_copy_section(&self) -> String {
146        let discovered = ExtensionLoader::discover(self.project_root);
147
148        if discovered.is_empty() {
149            return String::new();
150        }
151
152        let ext_dirs: HashSet<PathBuf> = discovered
153            .iter()
154            .filter_map(|ext| ext.path.strip_prefix(self.project_root).ok())
155            .map(Path::to_path_buf)
156            .collect();
157
158        if ext_dirs.is_empty() {
159            return String::new();
160        }
161
162        let mut sorted_dirs: Vec<_> = ext_dirs.into_iter().collect();
163        sorted_dirs.sort();
164
165        let copy_lines: Vec<_> = sorted_dirs
166            .iter()
167            .map(|dir| {
168                format!(
169                    "COPY {} {}/{}",
170                    dir.display(),
171                    container::APP,
172                    dir.display()
173                )
174            })
175            .collect();
176
177        format!("\n# Copy extension assets\n{}\n", copy_lines.join("\n"))
178    }
179
180    fn mcp_copy_section(&self) -> String {
181        let binaries = self.services_config.as_ref().map_or_else(
182            || ExtensionLoader::get_mcp_binary_names(self.project_root),
183            |config| ExtensionLoader::get_production_mcp_binary_names(self.project_root, config),
184        );
185
186        if binaries.is_empty() {
187            return String::new();
188        }
189
190        let lines: Vec<String> = binaries
191            .iter()
192            .map(|bin| format!("COPY target/release/{} {}/", bin, container::BIN))
193            .collect();
194
195        format!("\n# Copy MCP server binaries\n{}\n", lines.join("\n"))
196    }
197
198    fn env_section(&self) -> String {
199        let profile_env = self.profile_name.map_or_else(String::new, |name| {
200            format!(
201                "    SYSTEMPROMPT_PROFILE={}/{}/profile.yaml \\",
202                container::PROFILES,
203                name
204            )
205        });
206
207        if profile_env.is_empty() {
208            format!(
209                r#"ENV HOST=0.0.0.0 \
210    PORT=8080 \
211    RUST_LOG=info \
212    PATH="{}:$PATH" \
213    SYSTEMPROMPT_SERVICES_PATH={} \
214    SYSTEMPROMPT_TEMPLATES_PATH={} \
215    SYSTEMPROMPT_ASSETS_PATH={}"#,
216                container::BIN,
217                container::SERVICES,
218                container::TEMPLATES,
219                container::ASSETS
220            )
221        } else {
222            format!(
223                r#"ENV HOST=0.0.0.0 \
224    PORT=8080 \
225    RUST_LOG=info \
226    PATH="{}:$PATH" \
227{}
228    SYSTEMPROMPT_SERVICES_PATH={} \
229    SYSTEMPROMPT_TEMPLATES_PATH={} \
230    SYSTEMPROMPT_ASSETS_PATH={}"#,
231                container::BIN,
232                profile_env,
233                container::SERVICES,
234                container::TEMPLATES,
235                container::ASSETS
236            )
237        }
238    }
239}