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
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    // control's deploy action requires `bundle_size` (it signs the bound into
435    // the bundle's presigned PUT), so the wire payload must always carry it.
436    // `supports_static_asset_cache_control` decides whether control signs
437    // Cache-Control into the static upload URLs, and this crate always sends
438    // the header, so it must never go out false.
439    #[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}