Skip to main content

systemprompt_sync/
crate_deploy.rs

1//! End-to-end "build crate, push docker image, deploy" flow used by
2//! `systemprompt cloud deploy` for the rust-side container image.
3//!
4//! All process execution flows through the
5//! [`CommandRunner`] seam so tests can
6//! substitute a stub instead of spawning real `cargo`/`git`/`docker`.
7//!
8//! Copyright (c) systemprompt.io — Business Source License 1.1.
9//! See <https://systemprompt.io> for licensing details.
10
11use std::env;
12use std::path::{Path, PathBuf};
13
14use systemprompt_cloud::{CommandRunner, CommandSpec, SystemCommandRunner};
15
16use crate::api_client::SyncApiClient;
17use crate::error::{SyncError, SyncResult};
18use crate::{SyncConfig, SyncOperationResult};
19
20pub struct CrateDeployService {
21    config: SyncConfig,
22    api_client: SyncApiClient,
23    runner: Box<dyn CommandRunner>,
24}
25
26impl std::fmt::Debug for CrateDeployService {
27    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
28        f.debug_struct("CrateDeployService")
29            .field("config", &self.config)
30            .field("api_client", &self.api_client)
31            .finish_non_exhaustive()
32    }
33}
34
35impl CrateDeployService {
36    #[must_use]
37    pub fn new(config: SyncConfig, api_client: SyncApiClient) -> Self {
38        Self {
39            config,
40            api_client,
41            runner: Box::new(SystemCommandRunner),
42        }
43    }
44
45    #[must_use]
46    pub fn with_runner(mut self, runner: Box<dyn CommandRunner>) -> Self {
47        self.runner = runner;
48        self
49    }
50
51    pub async fn deploy(
52        &self,
53        skip_build: bool,
54        custom_tag: Option<String>,
55    ) -> SyncResult<SyncOperationResult> {
56        let project_root = Self::get_project_root()?;
57        let app_id = self.get_app_id().await?;
58
59        let tag = if let Some(t) = custom_tag {
60            t
61        } else {
62            let timestamp = chrono::Utc::now().timestamp();
63            let git_sha = self.get_git_sha()?;
64            format!("deploy-{timestamp}-{git_sha}")
65        };
66
67        let image = format!("registry.fly.io/{app_id}:{tag}");
68
69        if !skip_build {
70            self.build_release(&project_root)?;
71        }
72
73        self.build_docker(&project_root, &image)?;
74
75        let token = self
76            .api_client
77            .get_registry_token(&self.config.tenant_id)
78            .await?;
79        self.docker_login(&token.registry, &token.username, &token.token)?;
80        self.docker_push(&image)?;
81
82        let response = self
83            .api_client
84            .deploy(&self.config.tenant_id, &image)
85            .await?;
86
87        Ok(
88            SyncOperationResult::success("crate_deploy", 1).with_details(serde_json::json!({
89                "image": image,
90                "status": response.status,
91                "app_url": response.app_url,
92            })),
93        )
94    }
95
96    fn get_project_root() -> SyncResult<PathBuf> {
97        let current = env::current_dir()?;
98        if current.join("infrastructure").exists() {
99            Ok(current)
100        } else {
101            Err(SyncError::NotProjectRoot)
102        }
103    }
104
105    async fn get_app_id(&self) -> SyncResult<String> {
106        self.api_client
107            .get_tenant_app_id(&self.config.tenant_id)
108            .await
109    }
110
111    fn get_git_sha(&self) -> SyncResult<String> {
112        let spec = CommandSpec {
113            program: "git".to_owned(),
114            args: vec![
115                "rev-parse".to_owned(),
116                "--short".to_owned(),
117                "HEAD".to_owned(),
118            ],
119            current_dir: None,
120        };
121        let output = self
122            .runner
123            .output(&spec)
124            .map_err(|source| SyncError::CommandSpawnFailed {
125                command: spec.rendered(),
126                source,
127            })?;
128
129        String::from_utf8(output.stdout)
130            .map(|sha| sha.trim().to_owned())
131            .map_err(|_e| SyncError::GitShaUnavailable)
132    }
133
134    fn build_release(&self, project_root: &Path) -> SyncResult<()> {
135        self.run_command(
136            "cargo",
137            &[
138                "build",
139                "--release",
140                "--manifest-path=core/Cargo.toml",
141                "--bin",
142                "systemprompt",
143            ],
144            project_root,
145        )
146    }
147
148    fn build_docker(&self, project_root: &Path, image: &str) -> SyncResult<()> {
149        self.run_command(
150            "docker",
151            &[
152                "build",
153                "-f",
154                "infrastructure/docker/app.Dockerfile",
155                "-t",
156                image,
157                ".",
158            ],
159            project_root,
160        )
161    }
162
163    fn docker_login(&self, registry: &str, username: &str, token: &str) -> SyncResult<()> {
164        let spec = CommandSpec {
165            program: "docker".to_owned(),
166            args: vec![
167                "login".to_owned(),
168                registry.to_owned(),
169                "-u".to_owned(),
170                username.to_owned(),
171                "--password-stdin".to_owned(),
172            ],
173            current_dir: None,
174        };
175        let status = self
176            .runner
177            .status_with_stdin(&spec, token.as_bytes())
178            .map_err(|source| SyncError::CommandSpawnFailed {
179                command: format!("docker login {registry}"),
180                source,
181            })?;
182
183        if !status.success() {
184            return Err(SyncError::DockerLoginFailed);
185        }
186        Ok(())
187    }
188
189    fn docker_push(&self, image: &str) -> SyncResult<()> {
190        self.run_command("docker", &["push", image], &env::current_dir()?)
191    }
192
193    fn run_command(&self, cmd: &str, args: &[&str], dir: &Path) -> SyncResult<()> {
194        let spec = CommandSpec {
195            program: cmd.to_owned(),
196            args: args.iter().map(|a| (*a).to_owned()).collect(),
197            current_dir: Some(dir.to_path_buf()),
198        };
199        let command_str = spec.rendered();
200        let status = self
201            .runner
202            .status(&spec)
203            .map_err(|source| SyncError::CommandSpawnFailed {
204                command: command_str.clone(),
205                source,
206            })?;
207
208        if !status.success() {
209            return Err(SyncError::CommandFailed {
210                command: command_str,
211            });
212        }
213        Ok(())
214    }
215}