use std::collections::HashSet;
use std::path::{Path, PathBuf};
use systemprompt_cloud::constants::{container, storage};
use systemprompt_extension::ExtensionRegistry;
use systemprompt_loader::{ConfigLoader, ExtensionLoader};
use systemprompt_models::{CliPaths, ServicesConfig};
use super::super::tenant::find_services_config;
#[derive(Debug)]
pub struct DockerfileBuilder<'a> {
project_root: &'a Path,
profile_name: Option<&'a str>,
services_config: Option<ServicesConfig>,
}
impl<'a> DockerfileBuilder<'a> {
pub fn new(project_root: &'a Path) -> Self {
let services_config = find_services_config(project_root)
.map_err(|e| {
tracing::debug!(error = %e, "No services config found for dockerfile generation");
e
})
.ok()
.and_then(|path| {
ConfigLoader::load_from_path(&path)
.map_err(|e| {
tracing::warn!(error = %e, "Failed to load services config");
e
})
.ok()
});
Self {
project_root,
profile_name: None,
services_config,
}
}
pub const fn with_profile(mut self, name: &'a str) -> Self {
self.profile_name = Some(name);
self
}
pub fn build(&self) -> String {
let mcp_section = self.mcp_copy_section();
let env_section = self.env_section();
let extension_dirs = Self::extension_storage_dirs();
let extension_assets_section = self.extension_asset_copy_section();
format!(
r#"# systemprompt.io Application Dockerfile
# Built by: systemprompt cloud profile create
# Used by: systemprompt cloud deploy
FROM debian:bookworm-slim
# Install runtime dependencies
RUN apt-get update && apt-get install -y \
ca-certificates \
curl \
libssl3 \
libpq5 \
lsof \
&& rm -rf /var/lib/apt/lists/*
RUN useradd -m -u 1000 app
WORKDIR {app}
RUN mkdir -p {bin} {logs} {storage}/{images} {storage}/{generated} {storage}/{logos} {storage}/{audio} {storage}/{video} {storage}/{documents} {storage}/{uploads} {web}{extension_dirs}
# Copy pre-built binaries
COPY target/release/systemprompt {bin}/
{mcp_section}
# Copy storage assets (images, etc.)
COPY storage {storage}
# Copy web dist (generated HTML, CSS, JS)
COPY web/dist {web_dist}
{extension_assets_section}
# Copy services configuration
COPY services {services_path}
# Copy profiles
COPY .systemprompt/profiles {profiles}
RUN chmod +x {bin}/* && chown -R app:app {app}
USER app
EXPOSE 8080
# Environment configuration
{env_section}
HEALTHCHECK --interval=30s --timeout=10s --start-period=10s --retries=3 \
CMD curl -f http://localhost:8080/api/v1/health || exit 1
CMD ["{bin}/systemprompt", "{cmd_infra}", "{cmd_services}", "{cmd_serve}", "--foreground"]
"#,
app = container::APP,
bin = container::BIN,
logs = container::LOGS,
storage = container::STORAGE,
web = container::WEB,
web_dist = container::WEB_DIST,
services_path = container::SERVICES,
profiles = container::PROFILES,
images = storage::IMAGES,
generated = storage::GENERATED,
logos = storage::LOGOS,
audio = storage::AUDIO,
video = storage::VIDEO,
documents = storage::DOCUMENTS,
uploads = storage::UPLOADS,
extension_dirs = extension_dirs,
mcp_section = mcp_section,
env_section = env_section,
extension_assets_section = extension_assets_section,
cmd_infra = CliPaths::INFRA,
cmd_services = CliPaths::SERVICES,
cmd_serve = CliPaths::SERVE,
)
}
fn extension_storage_dirs() -> String {
let registry = ExtensionRegistry::discover();
let paths = registry.all_required_storage_paths();
if paths.is_empty() {
return String::new();
}
let mut result = String::new();
for path in paths {
result.push(' ');
result.push_str(container::STORAGE);
result.push('/');
result.push_str(path);
}
result
}
fn extension_asset_copy_section(&self) -> String {
let discovered = ExtensionLoader::discover(self.project_root);
if discovered.is_empty() {
return String::new();
}
let ext_dirs: HashSet<PathBuf> = discovered
.iter()
.filter_map(|ext| ext.path.strip_prefix(self.project_root).ok())
.map(Path::to_path_buf)
.collect();
if ext_dirs.is_empty() {
return String::new();
}
let mut sorted_dirs: Vec<_> = ext_dirs.into_iter().collect();
sorted_dirs.sort();
let copy_lines: Vec<_> = sorted_dirs
.iter()
.map(|dir| {
format!(
"COPY {} {}/{}",
dir.display(),
container::APP,
dir.display()
)
})
.collect();
format!("\n# Copy extension assets\n{}\n", copy_lines.join("\n"))
}
fn mcp_copy_section(&self) -> String {
let binaries = self.services_config.as_ref().map_or_else(
|| ExtensionLoader::get_mcp_binary_names(self.project_root),
|config| ExtensionLoader::get_production_mcp_binary_names(self.project_root, config),
);
if binaries.is_empty() {
return String::new();
}
let lines: Vec<String> = binaries
.iter()
.map(|bin| format!("COPY target/release/{} {}/", bin, container::BIN))
.collect();
format!("\n# Copy MCP server binaries\n{}\n", lines.join("\n"))
}
fn env_section(&self) -> String {
let profile_env = self.profile_name.map_or_else(String::new, |name| {
format!(
" SYSTEMPROMPT_PROFILE={}/{}/profile.yaml \\",
container::PROFILES,
name
)
});
if profile_env.is_empty() {
format!(
r#"ENV HOST=0.0.0.0 \
PORT=8080 \
RUST_LOG=info \
PATH="{}:$PATH" \
SYSTEMPROMPT_SERVICES_PATH={} \
SYSTEMPROMPT_TEMPLATES_PATH={} \
SYSTEMPROMPT_ASSETS_PATH={}"#,
container::BIN,
container::SERVICES,
container::TEMPLATES,
container::ASSETS
)
} else {
format!(
r#"ENV HOST=0.0.0.0 \
PORT=8080 \
RUST_LOG=info \
PATH="{}:$PATH" \
{}
SYSTEMPROMPT_SERVICES_PATH={} \
SYSTEMPROMPT_TEMPLATES_PATH={} \
SYSTEMPROMPT_ASSETS_PATH={}"#,
container::BIN,
profile_env,
container::SERVICES,
container::TEMPLATES,
container::ASSETS
)
}
}
}