Skip to main content

fn0_deploy/
lib.rs

1use anyhow::{anyhow, Result};
2use serde::{Deserialize, Serialize};
3use std::path::{Path, PathBuf};
4
5pub const HQ_URL: &str = "http://fn0-hq.fn0.dev:8080";
6const GITHUB_CLIENT_ID: &str = "Ov23liRuIJf1NSe9ccP8";
7
8#[derive(Serialize, Deserialize)]
9struct Credentials {
10    github_token: String,
11}
12
13#[derive(Deserialize)]
14struct DeviceCodeResponse {
15    device_code: String,
16    user_code: String,
17    verification_uri: String,
18    interval: u64,
19}
20
21#[derive(Deserialize)]
22struct TokenResponse {
23    access_token: Option<String>,
24    error: Option<String>,
25}
26
27#[derive(Deserialize)]
28struct DeployStartResponse {
29    presigned_url: String,
30    deploy_job_id: String,
31    subdomain: String,
32    code_id: u64,
33}
34
35#[derive(Deserialize)]
36struct DeployFinishResponse {
37    job_id: String,
38}
39
40#[derive(Deserialize)]
41struct DeployStatusResponse {
42    delivered: bool,
43    hosts_total: usize,
44    hosts_at_target: usize,
45    hosts_pending: Vec<String>,
46    hosts_quarantined: Vec<String>,
47    #[serde(default)]
48    job: Option<DeployJobStatus>,
49}
50
51#[derive(Deserialize)]
52struct DeployJobStatus {
53    phase: String,
54    #[serde(default)]
55    #[allow(dead_code)]
56    generation: Option<u64>,
57    #[serde(default)]
58    last_error: Option<String>,
59}
60
61fn credentials_path() -> Result<PathBuf> {
62    let home = std::env::var("HOME").map_err(|_| anyhow!("Cannot find HOME directory"))?;
63    Ok(PathBuf::from(home).join(".fn0").join("credentials"))
64}
65
66fn load_credentials() -> Result<Option<Credentials>> {
67    let path = credentials_path()?;
68    if !path.exists() {
69        return Ok(None);
70    }
71    let content = std::fs::read_to_string(&path)?;
72    let creds: Credentials = serde_json::from_str(&content)?;
73    Ok(Some(creds))
74}
75
76fn save_credentials(creds: &Credentials) -> Result<()> {
77    let path = credentials_path()?;
78    if let Some(parent) = path.parent() {
79        std::fs::create_dir_all(parent)?;
80    }
81    std::fs::write(&path, serde_json::to_string_pretty(creds)?)?;
82    Ok(())
83}
84
85async fn github_device_flow() -> Result<String> {
86    let client = reqwest::Client::new();
87
88    let resp: DeviceCodeResponse = client
89        .post("https://github.com/login/device/code")
90        .header("Accept", "application/json")
91        .form(&[("client_id", GITHUB_CLIENT_ID), ("scope", "read:user")])
92        .send()
93        .await?
94        .json()
95        .await?;
96
97    println!("\nGitHub authentication required.");
98    println!("Open {} in your browser", resp.verification_uri);
99    println!("and enter the code: {}\n", resp.user_code);
100
101    let interval = std::time::Duration::from_secs(resp.interval.max(5));
102
103    loop {
104        tokio::time::sleep(interval).await;
105
106        let token_resp: TokenResponse = client
107            .post("https://github.com/login/oauth/access_token")
108            .header("Accept", "application/json")
109            .form(&[
110                ("client_id", GITHUB_CLIENT_ID),
111                ("device_code", resp.device_code.as_str()),
112                ("grant_type", "urn:ietf:params:oauth:grant-type:device_code"),
113            ])
114            .send()
115            .await?
116            .json()
117            .await?;
118
119        if let Some(token) = token_resp.access_token {
120            return Ok(token);
121        }
122
123        match token_resp.error.as_deref() {
124            Some("authorization_pending") => continue,
125            Some("slow_down") => {
126                tokio::time::sleep(std::time::Duration::from_secs(5)).await;
127                continue;
128            }
129            Some(e) => return Err(anyhow!("GitHub OAuth error: {}", e)),
130            None => continue,
131        }
132    }
133}
134
135pub async fn get_github_token() -> Result<String> {
136    if let Some(creds) = load_credentials()? {
137        return Ok(creds.github_token);
138    }
139
140    let token = github_device_flow().await?;
141    save_credentials(&Credentials {
142        github_token: token.clone(),
143    })?;
144    println!("Authentication complete! Token saved.\n");
145
146    Ok(token)
147}
148
149pub async fn deploy(
150    project_name: &str,
151    bundle_tar_path: &Path,
152    env_content: Option<String>,
153) -> Result<()> {
154    let github_token = get_github_token().await?;
155
156    let client = reqwest::Client::new();
157
158    println!("Requesting deploy start...");
159    let start_resp: DeployStartResponse = client
160        .post(format!("{}/deploy/start", HQ_URL))
161        .json(&serde_json::json!({
162            "github_token": github_token,
163            "project_name": project_name,
164        }))
165        .send()
166        .await?
167        .error_for_status()
168        .map_err(|e| anyhow!("Deploy start failed: {}", e))?
169        .json()
170        .await?;
171
172    println!("Subdomain: {}.fn0.dev", start_resp.subdomain);
173
174    println!("Uploading bundle...");
175    let bundle_bytes = std::fs::read(bundle_tar_path)
176        .map_err(|e| anyhow!("Failed to read {}: {}", bundle_tar_path.display(), e))?;
177
178    client
179        .put(&start_resp.presigned_url)
180        .header("content-type", "application/x-tar")
181        .body(bundle_bytes)
182        .send()
183        .await?
184        .error_for_status()
185        .map_err(|e| anyhow!("Bundle upload failed: {}", e))?;
186
187    println!("Requesting deploy finish...");
188    let finish_resp: DeployFinishResponse = client
189        .post(format!("{}/deploy/finish", HQ_URL))
190        .json(&serde_json::json!({
191            "github_token": github_token,
192            "deploy_job_id": start_resp.deploy_job_id,
193            "subdomain": start_resp.subdomain,
194            "code_id": start_resp.code_id,
195            "env": env_content,
196        }))
197        .send()
198        .await?
199        .error_for_status()
200        .map_err(|e| anyhow!("Deploy finish failed: {}", e))?
201        .json()
202        .await?;
203
204    println!("Deploy job queued: {}", finish_resp.job_id);
205
206    let poll_interval = std::time::Duration::from_secs(2);
207    let timeout = std::time::Duration::from_secs(600);
208    let start = std::time::Instant::now();
209    let mut last_phase: Option<String> = None;
210    let mut last_progress: Option<(usize, usize)> = None;
211
212    loop {
213        let status: DeployStatusResponse = client
214            .get(format!(
215                "{}/deploy/status?job_id={}",
216                HQ_URL, finish_resp.job_id
217            ))
218            .send()
219            .await?
220            .error_for_status()
221            .map_err(|e| anyhow!("Deploy status failed: {}", e))?
222            .json()
223            .await?;
224
225        if let Some(job) = status.job.as_ref() {
226            if last_phase.as_deref() != Some(job.phase.as_str()) {
227                println!("  phase: {}", job.phase);
228                last_phase = Some(job.phase.clone());
229            }
230            if job.phase == "failed" {
231                let msg = job
232                    .last_error
233                    .clone()
234                    .unwrap_or_else(|| "unknown error".to_string());
235                return Err(anyhow!("Deploy job failed: {}", msg));
236            }
237        }
238
239        let progress = (status.hosts_at_target, status.hosts_total);
240        if last_progress != Some(progress) {
241            println!("  {}/{} hosts ready", progress.0, progress.1);
242            last_progress = Some(progress);
243        }
244
245        let phase_done = status
246            .job
247            .as_ref()
248            .map(|j| j.phase == "done")
249            .unwrap_or(false);
250        if phase_done && status.delivered {
251            break;
252        }
253
254        if start.elapsed() > timeout {
255            return Err(anyhow!(
256                "Deploy timed out after {}s. phase={:?} pending={:?} quarantined={:?}",
257                timeout.as_secs(),
258                status.job.as_ref().map(|j| j.phase.clone()),
259                status.hosts_pending,
260                status.hosts_quarantined
261            ));
262        }
263
264        tokio::time::sleep(poll_interval).await;
265    }
266
267    println!("Deploy complete!");
268
269    Ok(())
270}
271
272#[derive(Deserialize)]
273struct AdminGrantResponse {
274    token: String,
275    subdomain: String,
276    #[allow(dead_code)]
277    expires_at: i64,
278}
279
280pub struct AdminRunOutput {
281    pub status: u16,
282    pub content_type: Option<String>,
283    pub body: Vec<u8>,
284}
285
286#[derive(Deserialize)]
287struct RenameStartResponse {
288    job_id: String,
289    #[serde(default)]
290    already_running: bool,
291}
292
293#[derive(Deserialize)]
294struct RenameStatusResponse {
295    job_id: String,
296    phase: String,
297    attempts: u32,
298    last_error: Option<String>,
299    is_terminal: bool,
300}
301
302pub async fn rename(project_name: &str, new_project_name: &str) -> Result<()> {
303    let github_token = get_github_token().await?;
304
305    let client = reqwest::Client::new();
306
307    println!("Requesting rename start...");
308    let start_resp: RenameStartResponse = client
309        .post(format!("{}/rename/start", HQ_URL))
310        .json(&serde_json::json!({
311            "github_token": github_token,
312            "project_name": project_name,
313            "new_project_name": new_project_name,
314        }))
315        .send()
316        .await?
317        .error_for_status()
318        .map_err(|e| anyhow!("Rename start failed: {}", e))?
319        .json()
320        .await?;
321
322    if start_resp.already_running {
323        println!("Following existing rename job: {}", start_resp.job_id);
324    } else {
325        println!("Rename job queued: {}", start_resp.job_id);
326    }
327
328    let poll_interval = std::time::Duration::from_secs(2);
329    let timeout = std::time::Duration::from_secs(900);
330    let start = std::time::Instant::now();
331    let mut last_phase: Option<String> = None;
332
333    loop {
334        let status: RenameStatusResponse = client
335            .get(format!(
336                "{}/rename/status?job_id={}",
337                HQ_URL, start_resp.job_id
338            ))
339            .send()
340            .await?
341            .error_for_status()
342            .map_err(|e| anyhow!("Rename status failed: {}", e))?
343            .json()
344            .await?;
345
346        if last_phase.as_deref() != Some(status.phase.as_str()) {
347            println!("  phase: {} (attempts={})", status.phase, status.attempts);
348            last_phase = Some(status.phase.clone());
349        }
350
351        if status.phase == "failed" {
352            let msg = status
353                .last_error
354                .unwrap_or_else(|| "unknown error".to_string());
355            return Err(anyhow!("Rename job failed: {}", msg));
356        }
357
358        if status.is_terminal && status.phase == "done" {
359            break;
360        }
361
362        if start.elapsed() > timeout {
363            return Err(anyhow!(
364                "Rename timed out after {}s. phase={:?}",
365                timeout.as_secs(),
366                status.phase
367            ));
368        }
369
370        let _ = status.job_id;
371        tokio::time::sleep(poll_interval).await;
372    }
373
374    println!("Rename complete!");
375    Ok(())
376}
377
378pub async fn admin_run(
379    project_name: &str,
380    task: &str,
381    input_body: Vec<u8>,
382    timeout_secs: u64,
383) -> Result<AdminRunOutput> {
384    let github_token = get_github_token().await?;
385
386    let client = reqwest::Client::builder()
387        .timeout(std::time::Duration::from_secs(timeout_secs))
388        .build()?;
389
390    let grant: AdminGrantResponse = client
391        .post(format!("{}/admin/grant", HQ_URL))
392        .json(&serde_json::json!({
393            "github_token": github_token,
394            "project_name": project_name,
395            "task": task,
396        }))
397        .send()
398        .await?
399        .error_for_status()
400        .map_err(|e| anyhow!("Admin grant request failed: {}", e))?
401        .json()
402        .await?;
403
404    let url = format!("https://{}.fn0.dev/__forte_admin/{}", grant.subdomain, task);
405    let resp = client
406        .post(&url)
407        .header("Authorization", format!("FortoAdmin {}", grant.token))
408        .header("Content-Type", "application/json")
409        .body(input_body)
410        .send()
411        .await?;
412
413    let status = resp.status().as_u16();
414    let content_type = resp
415        .headers()
416        .get("content-type")
417        .and_then(|v| v.to_str().ok())
418        .map(|s| s.to_string());
419    let body = resp.bytes().await?.to_vec();
420
421    Ok(AdminRunOutput {
422        status,
423        content_type,
424        body,
425    })
426}
427
428pub fn read_env_content(project_dir: &Path) -> Result<Option<String>> {
429    let env_path = project_dir.join(".env");
430    match std::fs::read_to_string(&env_path) {
431        Ok(content) => Ok(Some(content)),
432        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
433        Err(e) => Err(anyhow!("Failed to read {}: {}", env_path.display(), e)),
434    }
435}
436
437pub fn create_raw_bundle_wasm(wasm_path: &Path, output_path: &Path) -> Result<()> {
438    let file = std::fs::File::create(output_path)
439        .map_err(|e| anyhow!("Failed to create {}: {}", output_path.display(), e))?;
440    let mut builder = tar::Builder::new(file);
441
442    let manifest = br#"{"kind":"wasm"}"#;
443    append_bytes(&mut builder, "manifest.json", manifest)?;
444
445    let wasm_bytes = std::fs::read(wasm_path)
446        .map_err(|e| anyhow!("Failed to read {}: {}", wasm_path.display(), e))?;
447    append_bytes(&mut builder, "backend.wasm", &wasm_bytes)?;
448
449    builder.finish()?;
450    Ok(())
451}
452
453pub fn create_raw_bundle_forte(dist_dir: &Path, output_path: &Path) -> Result<()> {
454    let file = std::fs::File::create(output_path)
455        .map_err(|e| anyhow!("Failed to create {}: {}", output_path.display(), e))?;
456    let mut builder = tar::Builder::new(file);
457
458    let manifest = br#"{"kind":"wasmjs"}"#;
459    append_bytes(&mut builder, "manifest.json", manifest)?;
460
461    let backend_wasm = dist_dir.join("backend.wasm");
462    let wasm_bytes = std::fs::read(&backend_wasm)
463        .map_err(|e| anyhow!("Failed to read {}: {}", backend_wasm.display(), e))?;
464    append_bytes(&mut builder, "backend.wasm", &wasm_bytes)?;
465
466    let server_js = dist_dir.join("server.js");
467    let server_bytes = std::fs::read(&server_js)
468        .map_err(|e| anyhow!("Failed to read {}: {}", server_js.display(), e))?;
469    append_bytes(&mut builder, "entry.js", &server_bytes)?;
470
471    builder.finish()?;
472    Ok(())
473}
474
475fn append_bytes<W: std::io::Write>(
476    builder: &mut tar::Builder<W>,
477    path: &str,
478    data: &[u8],
479) -> Result<()> {
480    let mut header = tar::Header::new_gnu();
481    header.set_size(data.len() as u64);
482    header.set_mode(0o644);
483    header.set_cksum();
484    builder
485        .append_data(&mut header, path, data)
486        .map_err(|e| anyhow!("tar append failed for {}: {}", path, e))?;
487    Ok(())
488}