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
285 .remove(&file.relative_path)
286 .ok_or_else(|| {
287 anyhow!(
288 "control did not return presigned URL for {}",
289 file.relative_path
290 )
291 })?;
292 let bytes = std::fs::read(&file.absolute_path)
293 .map_err(|e| anyhow!("read {}: {}", file.absolute_path.display(), e))?;
294 let client = client.clone();
295 let content_type = file.content_type;
296 let path = file.relative_path.clone();
297 tasks.push(async move {
298 let mut request = client
299 .put(&upload.presigned_url)
300 .header("content-type", content_type);
301 if !upload.cache_control.is_empty() {
302 request = request.header("cache-control", &upload.cache_control);
303 }
304 let resp = request
305 .body(bytes)
306 .send()
307 .await
308 .map_err(|e| anyhow!("R2 PUT {}: {}", path, e))?;
309 resp.error_for_status()
310 .map_err(|e| anyhow!("R2 PUT {} HTTP error: {}", path, e))?;
311 Ok::<_, anyhow::Error>(())
312 });
313 }
314 while let Some(result) = tasks.next().await {
315 result?;
316 }
317 Ok(())
318}
319
320async fn poll_deploy_status(
321 client: &reqwest::Client,
322 control_url: &str,
323 token: &str,
324 project_id: &str,
325 code_version: u64,
326) -> Result<()> {
327 let url = format!(
328 "{}/__forte_action/deploy_status",
329 control_url.trim_end_matches('/')
330 );
331 let timeout = std::time::Duration::from_secs(600);
332 let start = std::time::Instant::now();
333 let mut last_state: Option<String> = None;
334
335 loop {
336 let raw: DeployStatus = client
337 .post(&url)
338 .bearer_auth(token)
339 .json(&DeployStatusInput {
340 project_id,
341 code_version,
342 })
343 .send()
344 .await?
345 .error_for_status()
346 .map_err(|e| anyhow!("deploy_status failed: {e}"))?
347 .json()
348 .await?;
349
350 match raw {
351 DeployStatus::Done {
352 active_version,
353 pending_version,
354 pending_compiled,
355 compiled_versions,
356 } => {
357 log_status_line(
358 &active_version,
359 &compiled_versions,
360 &pending_version,
361 pending_compiled,
362 &mut last_state,
363 );
364 return Ok(());
365 }
366 DeployStatus::Pending {
367 active_version,
368 pending_version,
369 pending_compiled,
370 compiled_versions,
371 } => {
372 log_status_line(
373 &active_version,
374 &compiled_versions,
375 &pending_version,
376 pending_compiled,
377 &mut last_state,
378 );
379 if start.elapsed() > timeout {
380 return Err(anyhow!(
381 "deploy_status timed out after {}s",
382 timeout.as_secs()
383 ));
384 }
385 }
386 DeployStatus::NoActiveVersion => {
387 return Err(anyhow!("control has no active fn0-wasmtime version yet"));
388 }
389 DeployStatus::NotLoggedIn => {
390 return Err(anyhow!("control rejected token; run `fn0 login` again."));
391 }
392 DeployStatus::NotFound => {
393 return Err(anyhow!(
394 "project '{project_id}' not found or not owned by you."
395 ));
396 }
397 DeployStatus::InternalError => {
398 return Err(anyhow!(
399 "deploy_status: server error; check fn0-control logs"
400 ));
401 }
402 }
403 }
404}
405
406fn log_status_line(
407 active_version: &str,
408 compiled_versions: &[String],
409 pending_version: &Option<String>,
410 pending_compiled: bool,
411 last_state: &mut Option<String>,
412) {
413 let state = format!(
414 "active={active_version} compiled={compiled_versions:?} pending={pending_version:?} pending_compiled={pending_compiled}",
415 );
416 if last_state.as_deref() != Some(&state) {
417 println!(" {state}");
418 *last_state = Some(state);
419 }
420}
421
422#[cfg(test)]
423mod tests {
424 use super::*;
425 use std::io::Write;
426
427 #[test]
428 fn bundle_size_reads_file_length() {
429 let mut file = tempfile::NamedTempFile::new().unwrap();
430 file.write_all(&[0u8; 1234]).unwrap();
431 assert_eq!(bundle_size(file.path()).unwrap(), 1234);
432 }
433
434 #[test]
440 fn deploy_input_carries_bundle_size_and_cache_control_support() {
441 let input = DeployInput {
442 project_id: "proj",
443 code_version: 42,
444 bundle_size: 999,
445 files: Vec::new(),
446 supports_static_asset_cache_control: true,
447 jobs: &[],
448 cron_updated_at: "2026-07-21T00:00:00Z",
449 };
450 let value = serde_json::to_value(&input).unwrap();
451 assert_eq!(value["bundle_size"], serde_json::json!(999));
452 assert_eq!(
453 value["supports_static_asset_cache_control"],
454 serde_json::json!(true)
455 );
456 }
457}