Skip to main content

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