1use crate::static_files::{StaticFile, collect_static_files};
2use anyhow::{Result, anyhow};
3use serde::{Deserialize, Serialize};
4use std::path::Path;
5
6#[derive(Serialize)]
7struct DeployInput<'a> {
8 project_id: &'a str,
9 code_version: u64,
10 bundle_size: u64,
11 files: Vec<DeployFile>,
12 supports_static_asset_cache_control: bool,
13 jobs: &'a [CronJob],
14 cron_updated_at: &'a str,
15}
16
17#[derive(Serialize, Deserialize, Clone, Debug)]
18pub struct CronJob {
19 pub function: String,
20 pub every_minutes: u32,
21}
22
23#[derive(Serialize)]
24struct DeployFile {
25 path: String,
26 size: u64,
27}
28
29#[derive(Deserialize)]
30#[serde(tag = "t", rename_all_fields = "camelCase")]
31enum Deploy {
32 Ok {
33 presigned_put_url: String,
34 object_key: String,
35 static_uploads: Vec<StaticUpload>,
36 },
37 QuotaExceeded {
38 reason: String,
39 },
40 BadCodeVersion {
41 reason: String,
42 },
43 NotLoggedIn,
44 NotFound,
45 InternalError,
46}
47
48#[derive(Deserialize)]
49#[serde(rename_all = "camelCase")]
50struct StaticUpload {
51 path: String,
52 presigned_url: String,
53 #[serde(default)]
57 cache_control: String,
58}
59
60#[derive(Serialize)]
61struct DeployStatusInput<'a> {
62 project_id: &'a str,
63 code_version: u64,
64}
65
66#[derive(Deserialize)]
67#[serde(tag = "t", rename_all_fields = "camelCase")]
68enum DeployStatus {
69 Done {
70 active_version: String,
71 pending_version: Option<String>,
72 pending_compiled: bool,
73 compiled_versions: Vec<String>,
74 },
75 Pending {
76 active_version: String,
77 pending_version: Option<String>,
78 pending_compiled: bool,
79 compiled_versions: Vec<String>,
80 },
81 NoActiveVersion,
82 NotLoggedIn,
83 NotFound,
84 InternalError,
85}
86
87#[allow(clippy::too_many_arguments)]
88pub async fn deploy_wasm(
89 control_url: &str,
90 token: &str,
91 project_id: &str,
92 code_version: u64,
93 bundle_tar_path: &Path,
94 jobs: &[CronJob],
95 cron_updated_at: &str,
96) -> Result<()> {
97 let client = reqwest::Client::new();
98 println!("project_id: {project_id}");
99
100 let bundle_size = bundle_size(bundle_tar_path)?;
101
102 let DeployOk {
103 presigned_put_url,
104 object_key,
105 static_uploads: _,
106 } = request_deploy(
107 &client,
108 control_url,
109 token,
110 project_id,
111 code_version,
112 Vec::new(),
113 bundle_size,
114 jobs,
115 cron_updated_at,
116 )
117 .await?;
118
119 println!("uploading bundle to {object_key} (code_version={code_version})...");
120 upload_bundle(&client, &presigned_put_url, bundle_tar_path).await?;
121
122 poll_deploy_status(&client, control_url, token, project_id, code_version).await?;
123 println!("Deploy complete!");
124 Ok(())
125}
126
127struct DeployOk {
128 presigned_put_url: String,
129 object_key: String,
130 static_uploads: Vec<StaticUpload>,
131}
132
133#[allow(clippy::too_many_arguments)]
134pub async fn deploy_forte(
135 control_url: &str,
136 token: &str,
137 project_id: &str,
138 code_version: u64,
139 fe_dist_dir: &Path,
140 bundle_tar_path: &Path,
141 jobs: &[CronJob],
142 cron_updated_at: &str,
143) -> Result<()> {
144 let client = reqwest::Client::new();
145 println!("project_id: {project_id}");
146
147 let static_files = collect_static_files(fe_dist_dir)?;
148 let deploy_files: Vec<DeployFile> = static_files
149 .iter()
150 .map(|f| DeployFile {
151 path: f.relative_path.clone(),
152 size: f.size,
153 })
154 .collect();
155 println!(
156 "Requesting deploy ({} static asset(s))...",
157 deploy_files.len()
158 );
159
160 let bundle_size = bundle_size(bundle_tar_path)?;
161
162 let DeployOk {
163 presigned_put_url,
164 object_key,
165 static_uploads,
166 } = request_deploy(
167 &client,
168 control_url,
169 token,
170 project_id,
171 code_version,
172 deploy_files,
173 bundle_size,
174 jobs,
175 cron_updated_at,
176 )
177 .await?;
178
179 if !static_files.is_empty() {
180 println!("Uploading {} static asset(s)...", static_files.len());
181 upload_static_assets(&client, &static_files, static_uploads).await?;
182 }
183
184 println!("uploading bundle to {object_key} (code_version={code_version})...");
185 upload_bundle(&client, &presigned_put_url, bundle_tar_path).await?;
186
187 poll_deploy_status(&client, control_url, token, project_id, code_version).await?;
188 println!("Deploy complete!");
189 Ok(())
190}
191
192#[allow(clippy::too_many_arguments)]
193async fn request_deploy(
194 client: &reqwest::Client,
195 control_url: &str,
196 token: &str,
197 project_id: &str,
198 code_version: u64,
199 files: Vec<DeployFile>,
200 bundle_size: u64,
201 jobs: &[CronJob],
202 cron_updated_at: &str,
203) -> Result<DeployOk> {
204 let deploy_url = format!(
205 "{}/__forte_action/deploy",
206 control_url.trim_end_matches('/')
207 );
208 let raw: Deploy = client
209 .post(&deploy_url)
210 .bearer_auth(token)
211 .json(&DeployInput {
212 project_id,
213 code_version,
214 bundle_size,
215 files,
216 supports_static_asset_cache_control: true,
217 jobs,
218 cron_updated_at,
219 })
220 .send()
221 .await?
222 .error_for_status()
223 .map_err(|e| anyhow!("deploy failed: {e}"))?
224 .json()
225 .await?;
226 match raw {
227 Deploy::Ok {
228 presigned_put_url,
229 object_key,
230 static_uploads,
231 } => Ok(DeployOk {
232 presigned_put_url,
233 object_key,
234 static_uploads,
235 }),
236 Deploy::QuotaExceeded { reason } => Err(anyhow!("deploy quota exceeded: {reason}")),
237 Deploy::BadCodeVersion { reason } => Err(anyhow!("deploy rejected code_version: {reason}")),
238 Deploy::NotLoggedIn => Err(anyhow!("control rejected token; run `fn0 login` again.")),
239 Deploy::NotFound => Err(anyhow!(
240 "project '{project_id}' not found or not owned by you."
241 )),
242 Deploy::InternalError => Err(anyhow!("deploy: server error; check fn0-control logs")),
243 }
244}
245
246fn bundle_size(bundle_tar_path: &Path) -> Result<u64> {
247 std::fs::metadata(bundle_tar_path)
248 .map(|metadata| metadata.len())
249 .map_err(|e| anyhow!("Failed to stat {}: {}", bundle_tar_path.display(), e))
250}
251
252async fn upload_bundle(
253 client: &reqwest::Client,
254 presigned_put_url: &str,
255 bundle_tar_path: &Path,
256) -> Result<()> {
257 let bundle_bytes = std::fs::read(bundle_tar_path)
258 .map_err(|e| anyhow!("Failed to read {}: {}", bundle_tar_path.display(), e))?;
259 client
260 .put(presigned_put_url)
261 .body(bundle_bytes)
262 .send()
263 .await?
264 .error_for_status()
265 .map_err(|e| anyhow!("bundle upload failed: {e}"))?;
266 Ok(())
267}
268
269async fn upload_static_assets(
270 client: &reqwest::Client,
271 files: &[StaticFile],
272 uploads: Vec<StaticUpload>,
273) -> Result<()> {
274 use futures::StreamExt;
275 use std::collections::HashMap;
276
277 let mut upload_for_path: HashMap<String, StaticUpload> = HashMap::new();
278 for u in uploads {
279 upload_for_path.insert(u.path.clone(), u);
280 }
281
282 let mut tasks = futures::stream::FuturesUnordered::new();
283 for file in files {
284 let upload = upload_for_path.remove(&file.relative_path).ok_or_else(|| {
285 anyhow!(
286 "control did not return presigned URL for {}",
287 file.relative_path
288 )
289 })?;
290 let bytes = std::fs::read(&file.absolute_path)
291 .map_err(|e| anyhow!("read {}: {}", file.absolute_path.display(), e))?;
292 let client = client.clone();
293 let content_type = file.content_type;
294 let path = file.relative_path.clone();
295 tasks.push(async move {
296 let mut request = client
297 .put(&upload.presigned_url)
298 .header("content-type", content_type);
299 if !upload.cache_control.is_empty() {
300 request = request.header("cache-control", &upload.cache_control);
301 }
302 let resp = request
303 .body(bytes)
304 .send()
305 .await
306 .map_err(|e| anyhow!("R2 PUT {}: {}", path, e))?;
307 resp.error_for_status()
308 .map_err(|e| anyhow!("R2 PUT {} HTTP error: {}", path, e))?;
309 Ok::<_, anyhow::Error>(())
310 });
311 }
312 while let Some(result) = tasks.next().await {
313 result?;
314 }
315 Ok(())
316}
317
318async fn poll_deploy_status(
319 client: &reqwest::Client,
320 control_url: &str,
321 token: &str,
322 project_id: &str,
323 code_version: u64,
324) -> Result<()> {
325 let url = format!(
326 "{}/__forte_action/deploy_status",
327 control_url.trim_end_matches('/')
328 );
329 let timeout = std::time::Duration::from_secs(600);
330 let start = std::time::Instant::now();
331 let mut last_state: Option<String> = None;
332
333 loop {
334 let raw: DeployStatus = client
335 .post(&url)
336 .bearer_auth(token)
337 .json(&DeployStatusInput {
338 project_id,
339 code_version,
340 })
341 .send()
342 .await?
343 .error_for_status()
344 .map_err(|e| anyhow!("deploy_status failed: {e}"))?
345 .json()
346 .await?;
347
348 match raw {
349 DeployStatus::Done {
350 active_version,
351 pending_version,
352 pending_compiled,
353 compiled_versions,
354 } => {
355 log_status_line(
356 &active_version,
357 &compiled_versions,
358 &pending_version,
359 pending_compiled,
360 &mut last_state,
361 );
362 return Ok(());
363 }
364 DeployStatus::Pending {
365 active_version,
366 pending_version,
367 pending_compiled,
368 compiled_versions,
369 } => {
370 log_status_line(
371 &active_version,
372 &compiled_versions,
373 &pending_version,
374 pending_compiled,
375 &mut last_state,
376 );
377 if start.elapsed() > timeout {
378 return Err(anyhow!(
379 "deploy_status timed out after {}s",
380 timeout.as_secs()
381 ));
382 }
383 }
384 DeployStatus::NoActiveVersion => {
385 return Err(anyhow!("control has no active fn0-wasmtime version yet"));
386 }
387 DeployStatus::NotLoggedIn => {
388 return Err(anyhow!("control rejected token; run `fn0 login` again."));
389 }
390 DeployStatus::NotFound => {
391 return Err(anyhow!(
392 "project '{project_id}' not found or not owned by you."
393 ));
394 }
395 DeployStatus::InternalError => {
396 return Err(anyhow!(
397 "deploy_status: server error; check fn0-control logs"
398 ));
399 }
400 }
401 }
402}
403
404fn log_status_line(
405 active_version: &str,
406 compiled_versions: &[String],
407 pending_version: &Option<String>,
408 pending_compiled: bool,
409 last_state: &mut Option<String>,
410) {
411 let state = format!(
412 "active={active_version} compiled={compiled_versions:?} pending={pending_version:?} pending_compiled={pending_compiled}",
413 );
414 if last_state.as_deref() != Some(&state) {
415 println!(" {state}");
416 *last_state = Some(state);
417 }
418}
419
420#[cfg(test)]
421mod tests {
422 use super::*;
423 use std::io::Write;
424
425 #[test]
426 fn bundle_size_reads_file_length() {
427 let mut file = tempfile::NamedTempFile::new().unwrap();
428 file.write_all(&[0u8; 1234]).unwrap();
429 assert_eq!(bundle_size(file.path()).unwrap(), 1234);
430 }
431
432 #[test]
438 fn deploy_input_carries_bundle_size_and_cache_control_support() {
439 let input = DeployInput {
440 project_id: "proj",
441 code_version: 42,
442 bundle_size: 999,
443 files: Vec::new(),
444 supports_static_asset_cache_control: true,
445 jobs: &[],
446 cron_updated_at: "2026-07-21T00:00:00Z",
447 };
448 let value = serde_json::to_value(&input).unwrap();
449 assert_eq!(value["bundle_size"], serde_json::json!(999));
450 assert_eq!(
451 value["supports_static_asset_cache_control"],
452 serde_json::json!(true)
453 );
454 }
455}