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    generation: u64,
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}
48
49fn credentials_path() -> Result<PathBuf> {
50    let home = std::env::var("HOME").map_err(|_| anyhow!("Cannot find HOME directory"))?;
51    Ok(PathBuf::from(home).join(".fn0").join("credentials"))
52}
53
54fn load_credentials() -> Result<Option<Credentials>> {
55    let path = credentials_path()?;
56    if !path.exists() {
57        return Ok(None);
58    }
59    let content = std::fs::read_to_string(&path)?;
60    let creds: Credentials = serde_json::from_str(&content)?;
61    Ok(Some(creds))
62}
63
64fn save_credentials(creds: &Credentials) -> Result<()> {
65    let path = credentials_path()?;
66    if let Some(parent) = path.parent() {
67        std::fs::create_dir_all(parent)?;
68    }
69    std::fs::write(&path, serde_json::to_string_pretty(creds)?)?;
70    Ok(())
71}
72
73async fn github_device_flow() -> Result<String> {
74    let client = reqwest::Client::new();
75
76    let resp: DeviceCodeResponse = client
77        .post("https://github.com/login/device/code")
78        .header("Accept", "application/json")
79        .form(&[("client_id", GITHUB_CLIENT_ID), ("scope", "read:user")])
80        .send()
81        .await?
82        .json()
83        .await?;
84
85    println!("\nGitHub authentication required.");
86    println!("Open {} in your browser", resp.verification_uri);
87    println!("and enter the code: {}\n", resp.user_code);
88
89    let interval = std::time::Duration::from_secs(resp.interval.max(5));
90
91    loop {
92        tokio::time::sleep(interval).await;
93
94        let token_resp: TokenResponse = client
95            .post("https://github.com/login/oauth/access_token")
96            .header("Accept", "application/json")
97            .form(&[
98                ("client_id", GITHUB_CLIENT_ID),
99                ("device_code", resp.device_code.as_str()),
100                ("grant_type", "urn:ietf:params:oauth:grant-type:device_code"),
101            ])
102            .send()
103            .await?
104            .json()
105            .await?;
106
107        if let Some(token) = token_resp.access_token {
108            return Ok(token);
109        }
110
111        match token_resp.error.as_deref() {
112            Some("authorization_pending") => continue,
113            Some("slow_down") => {
114                tokio::time::sleep(std::time::Duration::from_secs(5)).await;
115                continue;
116            }
117            Some(e) => return Err(anyhow!("GitHub OAuth error: {}", e)),
118            None => continue,
119        }
120    }
121}
122
123pub async fn get_github_token() -> Result<String> {
124    if let Some(creds) = load_credentials()? {
125        return Ok(creds.github_token);
126    }
127
128    let token = github_device_flow().await?;
129    save_credentials(&Credentials {
130        github_token: token.clone(),
131    })?;
132    println!("Authentication complete! Token saved.\n");
133
134    Ok(token)
135}
136
137pub async fn deploy(
138    project_name: &str,
139    bundle_tar_path: &Path,
140    env_content: Option<String>,
141) -> Result<()> {
142    let github_token = get_github_token().await?;
143
144    let client = reqwest::Client::new();
145
146    println!("Requesting deploy start...");
147    let start_resp: DeployStartResponse = client
148        .post(format!("{}/deploy/start", HQ_URL))
149        .json(&serde_json::json!({
150            "github_token": github_token,
151            "project_name": project_name,
152        }))
153        .send()
154        .await?
155        .error_for_status()
156        .map_err(|e| anyhow!("Deploy start failed: {}", e))?
157        .json()
158        .await?;
159
160    println!("Subdomain: {}.fn0.dev", start_resp.subdomain);
161
162    println!("Uploading bundle...");
163    let bundle_bytes = std::fs::read(bundle_tar_path)
164        .map_err(|e| anyhow!("Failed to read {}: {}", bundle_tar_path.display(), e))?;
165
166    client
167        .put(&start_resp.presigned_url)
168        .header("content-type", "application/x-tar")
169        .body(bundle_bytes)
170        .send()
171        .await?
172        .error_for_status()
173        .map_err(|e| anyhow!("Bundle upload failed: {}", e))?;
174
175    println!("Requesting deploy finish...");
176    let finish_resp: DeployFinishResponse = client
177        .post(format!("{}/deploy/finish", HQ_URL))
178        .json(&serde_json::json!({
179            "github_token": github_token,
180            "deploy_job_id": start_resp.deploy_job_id,
181            "subdomain": start_resp.subdomain,
182            "code_id": start_resp.code_id,
183            "env": env_content,
184        }))
185        .send()
186        .await?
187        .error_for_status()
188        .map_err(|e| anyhow!("Deploy finish failed: {}", e))?
189        .json()
190        .await?;
191
192    println!(
193        "Waiting for rollout to all workers (generation {})...",
194        finish_resp.generation
195    );
196
197    let poll_interval = std::time::Duration::from_secs(2);
198    let timeout = std::time::Duration::from_secs(300);
199    let start = std::time::Instant::now();
200    let mut last_progress: Option<(usize, usize)> = None;
201
202    loop {
203        let status: DeployStatusResponse = client
204            .get(format!(
205                "{}/deploy/status?generation={}",
206                HQ_URL, finish_resp.generation
207            ))
208            .send()
209            .await?
210            .error_for_status()
211            .map_err(|e| anyhow!("Deploy status failed: {}", e))?
212            .json()
213            .await?;
214
215        let progress = (status.hosts_at_target, status.hosts_total);
216        if last_progress != Some(progress) {
217            println!("  {}/{} hosts ready", progress.0, progress.1);
218            last_progress = Some(progress);
219        }
220
221        if status.delivered {
222            break;
223        }
224
225        if start.elapsed() > timeout {
226            return Err(anyhow!(
227                "Deploy rollout timed out after {}s. pending={:?} quarantined={:?}",
228                timeout.as_secs(),
229                status.hosts_pending,
230                status.hosts_quarantined
231            ));
232        }
233
234        tokio::time::sleep(poll_interval).await;
235    }
236
237    println!("Deploy complete!");
238
239    Ok(())
240}
241
242pub fn read_env_content(project_dir: &Path) -> Result<Option<String>> {
243    let env_path = project_dir.join(".env");
244    match std::fs::read_to_string(&env_path) {
245        Ok(content) => Ok(Some(content)),
246        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
247        Err(e) => Err(anyhow!("Failed to read {}: {}", env_path.display(), e)),
248    }
249}
250
251pub fn create_raw_bundle_wasm(wasm_path: &Path, output_path: &Path) -> Result<()> {
252    let file = std::fs::File::create(output_path)
253        .map_err(|e| anyhow!("Failed to create {}: {}", output_path.display(), e))?;
254    let mut builder = tar::Builder::new(file);
255
256    let manifest = br#"{"kind":"wasm"}"#;
257    append_bytes(&mut builder, "manifest.json", manifest)?;
258
259    let wasm_bytes = std::fs::read(wasm_path)
260        .map_err(|e| anyhow!("Failed to read {}: {}", wasm_path.display(), e))?;
261    append_bytes(&mut builder, "backend.wasm", &wasm_bytes)?;
262
263    builder.finish()?;
264    Ok(())
265}
266
267pub fn create_raw_bundle_forte(dist_dir: &Path, output_path: &Path) -> Result<()> {
268    let file = std::fs::File::create(output_path)
269        .map_err(|e| anyhow!("Failed to create {}: {}", output_path.display(), e))?;
270    let mut builder = tar::Builder::new(file);
271
272    let manifest = br#"{"kind":"wasmjs"}"#;
273    append_bytes(&mut builder, "manifest.json", manifest)?;
274
275    let backend_wasm = dist_dir.join("backend.wasm");
276    let wasm_bytes = std::fs::read(&backend_wasm)
277        .map_err(|e| anyhow!("Failed to read {}: {}", backend_wasm.display(), e))?;
278    append_bytes(&mut builder, "backend.wasm", &wasm_bytes)?;
279
280    let server_js = dist_dir.join("server.js");
281    let server_bytes = std::fs::read(&server_js)
282        .map_err(|e| anyhow!("Failed to read {}: {}", server_js.display(), e))?;
283    append_bytes(&mut builder, "entry.js", &server_bytes)?;
284
285    builder.finish()?;
286    Ok(())
287}
288
289fn append_bytes<W: std::io::Write>(
290    builder: &mut tar::Builder<W>,
291    path: &str,
292    data: &[u8],
293) -> Result<()> {
294    let mut header = tar::Header::new_gnu();
295    header.set_size(data.len() as u64);
296    header.set_mode(0o644);
297    header.set_cksum();
298    builder
299        .append_data(&mut header, path, data)
300        .map_err(|e| anyhow!("tar append failed for {}: {}", path, e))?;
301    Ok(())
302}