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