systemprompt_sync/
crate_deploy.rs1use 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}