synth_ai_core/api/
container.rs1use std::path::Path;
4use std::time::{Duration, Instant};
5
6use flate2::write::GzEncoder;
7use flate2::Compression;
8use tar::Builder;
9
10use crate::http::{HttpError, MultipartFile};
11use crate::CoreError;
12
13use super::client::SynthClient;
14use super::types::{
15 ContainerDeployResponse, ContainerDeploySpec, ContainerDeployStatus, ContainerDeploymentInfo,
16};
17
18const DEPLOYMENTS_ENDPOINT: &str = "/api/container/deployments";
19
20pub struct ContainerDeployClient<'a> {
22 client: &'a SynthClient,
23}
24
25impl<'a> ContainerDeployClient<'a> {
26 pub(crate) fn new(client: &'a SynthClient) -> Self {
27 Self { client }
28 }
29
30 pub async fn deploy_from_dir(
32 &self,
33 spec: ContainerDeploySpec,
34 context_dir: impl AsRef<Path>,
35 wait_for_ready: bool,
36 build_timeout_s: f64,
37 ) -> Result<ContainerDeployResponse, CoreError> {
38 let archive = package_context(context_dir.as_ref())?;
39 self.deploy_from_archive(spec, archive, wait_for_ready, build_timeout_s)
40 .await
41 }
42
43 pub async fn deploy_from_archive(
45 &self,
46 spec: ContainerDeploySpec,
47 archive_bytes: Vec<u8>,
48 wait_for_ready: bool,
49 build_timeout_s: f64,
50 ) -> Result<ContainerDeployResponse, CoreError> {
51 let spec_json = serde_json::to_string(&spec)
52 .map_err(|e| CoreError::Validation(format!("invalid spec: {}", e)))?;
53
54 let data = vec![("spec_json".to_string(), spec_json)];
55 let files = vec![MultipartFile::new(
56 "context",
57 "context.tar.gz",
58 archive_bytes,
59 Some("application/gzip".to_string()),
60 )];
61
62 let mut response: ContainerDeployResponse = self
63 .client
64 .http
65 .post_multipart(DEPLOYMENTS_ENDPOINT, &data, &files)
66 .await
67 .map_err(map_http_error)?;
68
69 if wait_for_ready {
70 response = self.wait_for_ready(response, build_timeout_s).await?;
71 }
72
73 Ok(response)
74 }
75
76 pub async fn get(&self, deployment_id: &str) -> Result<ContainerDeploymentInfo, CoreError> {
78 let path = format!("{}/{}", DEPLOYMENTS_ENDPOINT, deployment_id);
79 self.client
80 .http
81 .get(&path, None)
82 .await
83 .map_err(map_http_error)
84 }
85
86 pub async fn list(&self) -> Result<Vec<ContainerDeploymentInfo>, CoreError> {
88 self.client
89 .http
90 .get(DEPLOYMENTS_ENDPOINT, None)
91 .await
92 .map_err(map_http_error)
93 }
94
95 pub async fn status(&self, deployment_id: &str) -> Result<ContainerDeployStatus, CoreError> {
97 let path = format!("{}/{}/status", DEPLOYMENTS_ENDPOINT, deployment_id);
98 self.client
99 .http
100 .get(&path, None)
101 .await
102 .map_err(map_http_error)
103 }
104
105 async fn wait_for_ready(
106 &self,
107 mut response: ContainerDeployResponse,
108 build_timeout_s: f64,
109 ) -> Result<ContainerDeployResponse, CoreError> {
110 let deadline = Instant::now() + Duration::from_secs_f64(build_timeout_s.max(1.0));
111 while Instant::now() < deadline {
112 let status = self.status(&response.deployment_id).await?;
113 response.status = status.status;
114 if matches!(response.status.as_str(), "ready" | "failed") {
115 return Ok(response);
116 }
117 tokio::time::sleep(Duration::from_secs(5)).await;
118 }
119 Ok(response)
120 }
121}
122
123fn package_context(context_dir: &Path) -> Result<Vec<u8>, CoreError> {
124 if !context_dir.exists() {
125 return Err(CoreError::InvalidInput(format!(
126 "context dir not found: {}",
127 context_dir.display()
128 )));
129 }
130 if !context_dir.is_dir() {
131 return Err(CoreError::InvalidInput(format!(
132 "context path is not a directory: {}",
133 context_dir.display()
134 )));
135 }
136
137 let encoder = GzEncoder::new(Vec::new(), Compression::default());
138 let mut builder = Builder::new(encoder);
139 add_dir_to_archive(&mut builder, context_dir, context_dir)?;
140 builder
141 .finish()
142 .map_err(|e| CoreError::Internal(format!("failed to finish archive: {}", e)))?;
143 let encoder = builder
144 .into_inner()
145 .map_err(|e| CoreError::Internal(format!("failed to finalize archive: {}", e)))?;
146 encoder
147 .finish()
148 .map_err(|e| CoreError::Internal(format!("failed to write archive: {}", e)))
149}
150
151fn add_dir_to_archive(
152 builder: &mut Builder<GzEncoder<Vec<u8>>>,
153 base: &Path,
154 dir: &Path,
155) -> Result<(), CoreError> {
156 for entry in std::fs::read_dir(dir)
157 .map_err(|e| CoreError::Internal(format!("failed to read dir: {}", e)))?
158 {
159 let entry =
160 entry.map_err(|e| CoreError::Internal(format!("failed to read entry: {}", e)))?;
161 let path = entry.path();
162 if path.is_dir() {
163 add_dir_to_archive(builder, base, &path)?;
164 } else if path.is_file() {
165 let rel = path
166 .strip_prefix(base)
167 .map_err(|e| CoreError::Internal(format!("failed to strip path prefix: {}", e)))?;
168 builder.append_path_with_name(&path, rel).map_err(|e| {
169 CoreError::Internal(format!("failed to add file to archive: {}", e))
170 })?;
171 }
172 }
173 Ok(())
174}
175
176fn map_http_error(e: HttpError) -> CoreError {
177 match e {
178 HttpError::Response(detail) => {
179 if detail.status == 401 || detail.status == 403 {
180 CoreError::Authentication(format!("authentication failed: {}", detail))
181 } else if detail.status == 429 {
182 CoreError::UsageLimit(crate::UsageLimitInfo::from_http_429("container", &detail))
183 } else {
184 CoreError::HttpResponse(crate::HttpErrorInfo {
185 status: detail.status,
186 url: detail.url,
187 message: detail.message,
188 body_snippet: detail.body_snippet,
189 })
190 }
191 }
192 HttpError::Request(e) => CoreError::Http(e),
193 _ => CoreError::Internal(format!("{}", e)),
194 }
195}