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