Skip to main content

systemprompt_sync/deploy/
artifacts.rs

1//! Pre-deploy validation of the project's build artifacts.
2//!
3//! [`DeployArtifacts`] locates the release binary and profile Dockerfile,
4//! then asserts that required extension assets, storage, and template
5//! directories exist inside the Docker build context before a cloud deploy
6//! proceeds.
7//!
8//! Copyright (c) systemprompt.io — Business Source License 1.1.
9//! See <https://systemprompt.io> for licensing details.
10
11use std::path::{Path, PathBuf};
12
13use systemprompt_cloud::ProjectContext;
14use systemprompt_extension::{AssetPaths, ExtensionRegistry};
15use systemprompt_models::paths::constants::build;
16
17use crate::error::{SyncError, SyncResult};
18
19struct ProjectAssetPaths {
20    storage_files: PathBuf,
21    web_dist: PathBuf,
22}
23
24impl AssetPaths for ProjectAssetPaths {
25    fn storage_files(&self) -> &Path {
26        &self.storage_files
27    }
28    fn web_dist(&self) -> &Path {
29        &self.web_dist
30    }
31}
32
33#[derive(Debug)]
34pub struct DeployArtifacts {
35    pub binary: PathBuf,
36    pub dockerfile: PathBuf,
37    project_root: PathBuf,
38}
39
40impl DeployArtifacts {
41    pub fn resolve(project_root: &Path, profile_name: &str) -> SyncResult<Self> {
42        let binary = project_root
43            .join(build::CARGO_TARGET)
44            .join("release")
45            .join(build::BINARY_NAME);
46
47        let ctx = ProjectContext::new(project_root.to_path_buf());
48        let dockerfile = ctx.profile_dockerfile(profile_name);
49
50        let artifacts = Self {
51            binary,
52            dockerfile,
53            project_root: project_root.to_path_buf(),
54        };
55        artifacts.validate()?;
56        Ok(artifacts)
57    }
58
59    fn validate(&self) -> SyncResult<()> {
60        if !self.binary.exists() {
61            return Err(SyncError::BuildArtifacts(format!(
62                "Release binary not found: {}\n\nRun: cargo build --release --bin systemprompt",
63                self.binary.display()
64            )));
65        }
66
67        self.validate_extension_assets()?;
68        self.validate_storage_directory()?;
69        self.validate_templates_directory()?;
70
71        if !self.dockerfile.exists() {
72            return Err(SyncError::BuildArtifacts(format!(
73                "Dockerfile not found: {}\n\nCreate a Dockerfile at this location",
74                self.dockerfile.display()
75            )));
76        }
77
78        Ok(())
79    }
80
81    fn validate_extension_assets(&self) -> SyncResult<()> {
82        let paths = ProjectAssetPaths {
83            storage_files: self.project_root.join("storage/files"),
84            web_dist: self.project_root.join("web/dist"),
85        };
86        let registry = ExtensionRegistry::discover()?;
87        let mut missing = Vec::new();
88        let mut outside_context = Vec::new();
89
90        for ext in registry.asset_extensions() {
91            let ext_id = ext.id();
92            for asset in ext.required_assets(&paths) {
93                if !asset.is_required() {
94                    continue;
95                }
96
97                let source = asset.source();
98
99                if !source.exists() {
100                    missing.push(format!("[ext:{}] {}", ext_id, source.display()));
101                    continue;
102                }
103
104                if !source.starts_with(&self.project_root) {
105                    outside_context.push(format!(
106                        "[ext:{}] {} (not under {})",
107                        ext_id,
108                        source.display(),
109                        self.project_root.display()
110                    ));
111                }
112            }
113        }
114
115        if !missing.is_empty() {
116            return Err(SyncError::BuildArtifacts(format!(
117                "Missing required extension assets:\n  {}\n\nCreate these files or mark them as \
118                 optional.",
119                missing.join("\n  ")
120            )));
121        }
122
123        if !outside_context.is_empty() {
124            return Err(SyncError::BuildArtifacts(format!(
125                "Extension assets outside Docker build context:\n  {}\n\nMove assets inside the \
126                 project directory.",
127                outside_context.join("\n  ")
128            )));
129        }
130
131        Ok(())
132    }
133
134    fn validate_storage_directory(&self) -> SyncResult<()> {
135        let storage_dir = self.project_root.join("storage");
136
137        if !storage_dir.exists() {
138            return Err(SyncError::BuildArtifacts(format!(
139                "Storage directory not found: {}\n\nExpected: storage/\n\nCreate this directory \
140                 for files, images, and other assets.",
141                storage_dir.display()
142            )));
143        }
144
145        let files_dir = storage_dir.join("files");
146        if !files_dir.exists() {
147            return Err(SyncError::BuildArtifacts(format!(
148                "Storage files directory not found: {}\n\nExpected: storage/files/\n\nThis \
149                 directory is required for serving static assets.",
150                files_dir.display()
151            )));
152        }
153
154        Ok(())
155    }
156
157    fn validate_templates_directory(&self) -> SyncResult<()> {
158        let templates_dir = self.project_root.join("services/web/templates");
159
160        if !templates_dir.exists() {
161            return Err(SyncError::BuildArtifacts(format!(
162                "Templates directory not found: {}\n\nExpected: \
163                 services/web/templates/\n\nCreate this directory with your HTML templates.",
164                templates_dir.display()
165            )));
166        }
167
168        Ok(())
169    }
170}