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
4use std::env;
5use std::io::Write;
6use std::path::PathBuf;
7use std::process::Command;
8
9use crate::api_client::SyncApiClient;
10use crate::error::{SyncError, SyncResult};
11use crate::{SyncConfig, SyncOperationResult};
12
13#[derive(Debug)]
14pub struct CrateDeployService {
15    config: SyncConfig,
16    api_client: SyncApiClient,
17}
18
19impl CrateDeployService {
20    pub const fn new(config: SyncConfig, api_client: SyncApiClient) -> Self {
21        Self { config, api_client }
22    }
23
24    pub async fn deploy(
25        &self,
26        skip_build: bool,
27        custom_tag: Option<String>,
28    ) -> SyncResult<SyncOperationResult> {
29        let project_root = Self::get_project_root()?;
30        let app_id = self.get_app_id().await?;
31
32        let tag = if let Some(t) = custom_tag {
33            t
34        } else {
35            let timestamp = chrono::Utc::now().timestamp();
36            let git_sha = Self::get_git_sha()?;
37            format!("deploy-{timestamp}-{git_sha}")
38        };
39
40        let image = format!("registry.fly.io/{app_id}:{tag}");
41
42        if !skip_build {
43            Self::build_release(&project_root)?;
44        }
45
46        Self::build_docker(&project_root, &image)?;
47
48        let token = self
49            .api_client
50            .get_registry_token(&self.config.tenant_id)
51            .await?;
52        Self::docker_login(&token.registry, &token.username, &token.token)?;
53        Self::docker_push(&image)?;
54
55        let response = self
56            .api_client
57            .deploy(&self.config.tenant_id, &image)
58            .await?;
59
60        Ok(
61            SyncOperationResult::success("crate_deploy", 1).with_details(serde_json::json!({
62                "image": image,
63                "status": response.status,
64                "app_url": response.app_url,
65            })),
66        )
67    }
68
69    fn get_project_root() -> SyncResult<PathBuf> {
70        let current = env::current_dir()?;
71        if current.join("infrastructure").exists() {
72            Ok(current)
73        } else {
74            Err(SyncError::NotProjectRoot)
75        }
76    }
77
78    async fn get_app_id(&self) -> SyncResult<String> {
79        self.api_client
80            .get_tenant_app_id(&self.config.tenant_id)
81            .await
82    }
83
84    fn get_git_sha() -> SyncResult<String> {
85        let output = Command::new("git")
86            .args(["rev-parse", "--short", "HEAD"])
87            .output()
88            .map_err(|source| SyncError::CommandSpawnFailed {
89                command: "git rev-parse --short HEAD".into(),
90                source,
91            })?;
92
93        String::from_utf8(output.stdout)
94            .map(|sha| sha.trim().to_string())
95            .map_err(|_| SyncError::GitShaUnavailable)
96    }
97
98    fn build_release(project_root: &PathBuf) -> SyncResult<()> {
99        Self::run_command(
100            "cargo",
101            &[
102                "build",
103                "--release",
104                "--manifest-path=core/Cargo.toml",
105                "--bin",
106                "systemprompt",
107            ],
108            project_root,
109        )
110    }
111
112    fn build_docker(project_root: &PathBuf, image: &str) -> SyncResult<()> {
113        Self::run_command(
114            "docker",
115            &[
116                "build",
117                "-f",
118                "infrastructure/docker/app.Dockerfile",
119                "-t",
120                image,
121                ".",
122            ],
123            project_root,
124        )
125    }
126
127    fn docker_login(registry: &str, username: &str, token: &str) -> SyncResult<()> {
128        let mut command = Command::new("docker");
129        command.args(["login", registry, "-u", username, "--password-stdin"]);
130        command.stdin(std::process::Stdio::piped());
131
132        let mut child = command
133            .spawn()
134            .map_err(|source| SyncError::CommandSpawnFailed {
135                command: format!("docker login {registry}"),
136                source,
137            })?;
138        if let Some(mut stdin) = child.stdin.take() {
139            stdin.write_all(token.as_bytes())?;
140        }
141
142        let status = child.wait()?;
143        if !status.success() {
144            return Err(SyncError::DockerLoginFailed);
145        }
146        Ok(())
147    }
148
149    fn docker_push(image: &str) -> SyncResult<()> {
150        Self::run_command("docker", &["push", image], &env::current_dir()?)
151    }
152
153    fn run_command(cmd: &str, args: &[&str], dir: &PathBuf) -> SyncResult<()> {
154        let command_str = format!("{cmd} {}", args.join(" "));
155        let status = Command::new(cmd)
156            .args(args)
157            .current_dir(dir)
158            .status()
159            .map_err(|source| SyncError::CommandSpawnFailed {
160                command: command_str.clone(),
161                source,
162            })?;
163
164        if !status.success() {
165            return Err(SyncError::CommandFailed {
166                command: command_str,
167            });
168        }
169        Ok(())
170    }
171}