Skip to main content

fn0_deploy/
deploy.rs

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    // Signed into the presigned PUT by control, so it must be sent back
54    // verbatim: any other value fails the signature with 403. Empty when
55    // control did not sign one.
56    #[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    // control's deploy action requires `bundle_size` (it signs the bound into
433    // the bundle's presigned PUT), so the wire payload must always carry it.
434    // `supports_static_asset_cache_control` decides whether control signs
435    // Cache-Control into the static upload URLs, and this crate always sends
436    // the header, so it must never go out false.
437    #[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}