Skip to main content

synth_ai_core/api/
container.rs

1//! Managed Container deployment client.
2
3use 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
20/// Client for managed Container deployment APIs.
21pub 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    /// Deploy a Container from a context directory.
31    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    /// Deploy a Container from an in-memory archive.
44    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    /// Fetch a deployment by ID.
77    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    /// List deployments for the current organization.
87    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    /// Fetch deployment status.
96    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}