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