omni/commands/
up.rs

1use crate::models::ComponentStatus;
2use crate::ui::PremiumUI;
3use anyhow::anyhow;
4use anyhow::{Context, Result};
5use console::style;
6use dialoguer::{Confirm, Input, Select};
7use flate2::write::GzEncoder;
8use flate2::Compression;
9use ignore::WalkBuilder;
10use pathdiff;
11use reqwest::multipart::{Form, Part};
12use serde::Deserialize;
13use serde::Serialize;
14use std::path::PathBuf;
15use std::{fs::File, path::Path};
16use std::{thread, time::Duration};
17use tabled::Table;
18use tar::Builder;
19use tempfile::env::temp_dir;
20use tokio::{fs, task};
21
22#[derive(Debug, Serialize, Deserialize)]
23pub struct DeployPermissions {
24    max_file_count: u64,
25}
26
27impl PremiumUI {
28    pub async fn deploy_interactive(&self) -> Result<()> {
29        // Get project path
30        let project_path: String = Input::with_theme(&self.theme)
31            .with_prompt("Enter project path")
32            .default(".".into())
33            .interact_text()?;
34        let project_path = PathBuf::from(project_path);
35        let project_path = project_path.canonicalize().context("Failed to canonicalize project path")?;
36
37        // Validate project path
38        if !Path::new(&project_path).exists() {
39            println!("{}", style("Error: Project path does not exist.").red());
40            return Ok(());
41        }
42
43        // Environment selection
44        let environments = vec!["Development", "Staging", "Production"];
45        let env_selection = Select::with_theme(&self.theme)
46            .with_prompt("Select deployment environment")
47            .items(&environments)
48            .default(0)
49            .interact()?;
50
51        // Production confirmation
52        if environments[env_selection] == "Production" {
53            let confirm = Confirm::with_theme(&self.theme)
54                .with_prompt("⚠️  You're deploying to production. Are you sure?")
55                .default(false)
56                .interact()?;
57            if !confirm {
58                println!("{}", style("Deployment cancelled.").yellow());
59                return Ok(());
60            }
61        }
62
63        println!("\n{}", style("🚀 Initializing deployment...").cyan().bold());
64        // Create tarball
65        println!("{}", style("🗜️  Creating tarball...").cyan().bold());
66        let tarball_path = self
67            .create_tarball(&project_path.to_string_lossy())
68            .await
69            .context("Failed to create tarball")?;
70        println!("{}", style("🗜️  uploading").cyan().bold());
71        let path = Path::new(&project_path);
72        if !path.is_dir() {
73            print!("{}", style("Error: Not a directory").red());
74            return Err(anyhow!("Invalid project path"));
75        }
76        let project_path = Path::new(&project_path)
77            .canonicalize()
78            .expect("Failed to canonicalize path");
79        let project_name: String = project_path
80            .file_name()
81            .and_then(|s| s.to_str())
82            .map(String::from)
83            .expect("Unable to determine folder name"); // Upload tarball
84        self.upload_tarball(
85            &tarball_path,
86            environments[env_selection],
87            project_name.as_str(),
88        )
89        .await
90        .context("Failed to upload tarball")?;
91
92        // Clean up tarball
93        fs::remove_file(&tarball_path)
94            .await
95            .context("Failed to clean up tarball")?;
96
97        let steps = [
98            ("Analyzing project", 20),
99            ("Building containers", 40),
100            ("Pushing to registry", 30),
101            ("Configuring services", 25),
102            ("Starting components", 35),
103        ];
104
105        for (step, duration) in steps.iter() {
106            let pb = self.create_progress_bar(*duration, step);
107            for i in 0..*duration {
108                pb.inc(1);
109                thread::sleep(Duration::from_millis(100));
110
111                match i {
112                    5 => pb.set_message(format!("{} (scanning dependencies)", step)),
113                    15 => pb.set_message(format!("{} (optimizing)", step)),
114                    25 => pb.set_message(format!("{} (finalizing)", step)),
115                    _ => {}
116                }
117            }
118            pb.finish_with_message(format!("{} ✓", step));
119        }
120
121        let status_table = Table::new(vec![
122            ComponentStatus {
123                name: "Web Frontend".into(),
124                status: "Running".into(),
125                replicas: "3/3".into(),
126                cpu: "150m".into(),
127                memory: "256Mi".into(),
128            },
129            ComponentStatus {
130                name: "API Backend".into(),
131                status: "Running".into(),
132                replicas: "2/2".into(),
133                cpu: "200m".into(),
134                memory: "512Mi".into(),
135            },
136            ComponentStatus {
137                name: "Database".into(),
138                status: "Running".into(),
139                replicas: "1/1".into(),
140                cpu: "500m".into(),
141                memory: "1Gi".into(),
142            },
143        ])
144        .to_string();
145
146        println!("\n{}", style("📊 Deployment Status").cyan().bold());
147        println!("{}", status_table);
148        println!("\n{}", style("🌍 Application Endpoints").cyan().bold());
149        println!("Frontend: {}", style("https://app.example.com").green());
150        println!("API:      {}", style("https://api.example.com").green());
151        println!("Metrics:  {}", style("https://metrics.example.com").green());
152        println!(
153            "\n{}",
154            style("✨ Deployment completed successfully!")
155                .green()
156                .bold()
157        );
158        println!(
159            "{}",
160            style("Run 'omni status' to monitor your deployment.").dim()
161        );
162        Ok(())
163    }
164
165    async fn create_tarball(&self, project_path: &str) -> Result<String> {
166        // Canonicalize the project path first
167        let project_path = fs::canonicalize(project_path)
168            .await
169            .context("Failed to resolve project path")?;
170        let absolute_path = project_path.clone();
171        // Get the directory name - use the last component of the path
172        let project_name = absolute_path
173            .file_name()
174            .and_then(|name| name.to_str())
175            .unwrap_or_else(|| {
176                project_path
177                    .components()
178                    .last()
179                    .and_then(|comp| comp.as_os_str().to_str())
180                    .unwrap_or("project")
181            })
182            .to_string();
183
184        // Create tarball filename in temp directory
185        let temp_dir = temp_dir();
186        let tar_gz_path = temp_dir.join(format!("{}.tar.gz", project_name));
187
188        // Create a file for the tarball
189        let tar_gz = File::create(&tar_gz_path)?;
190        let enc = GzEncoder::new(tar_gz, Compression::default());
191        let builder = std::sync::Arc::new(std::sync::Mutex::new(Builder::new(enc)));
192
193        // Count total files first
194        let mut total_files = 0;
195        let walker = WalkBuilder::new(&project_path)
196            .hidden(false)
197            .git_ignore(true)
198            .git_global(true)
199            .git_exclude(true)
200            .build();
201
202        for entry in walker.filter_map(|e| e.ok()) {
203            if entry.file_type().map_or(false, |ft| ft.is_file()) {
204                total_files += 1;
205            }
206        }
207        
208        // Use the API client for permissions check
209        let permissions_url = self.api_client.base_url.clone() + "/deploy/permissions";
210        let max_file_count = self.api_client.get::<DeployPermissions>("/deploy/permissions").await;
211        
212        match max_file_count {
213            Ok(permissions) => {
214                if total_files > permissions.max_file_count {
215                    let too_many_files: i64 =
216                        total_files as i64 - permissions.max_file_count as i64;
217                    println!("{}",style(format!("The server had denied your deployment request. Your project contains {} too many files. ({}/{})",too_many_files,total_files,permissions.max_file_count)).red());
218                    std::process::exit(0);
219                }
220            },
221            Err(e) => {
222                eprintln!("{}", style(format!("Deployment failed: {e}",)).red().bold());
223                std::process::exit(0);
224            }
225        }
226        
227        if total_files > 5000 {
228            let path_str = format!("{}", project_path.display());
229            let current_path_str = style(format!(
230                "You are about to upload the entire of {}",
231                path_str
232            ))
233            .yellow()
234            .bold()
235            .underlined();
236            let prompt = format!("Your project contains more than 5000 files.
237Are you sure you would like to deploy it? This make take significant amounts of time and space on your machine.\n{}",
238                current_path_str);
239            let confirm = dialoguer::Confirm::with_theme(&self.theme)
240                .default(false)
241                .with_prompt(prompt)
242                .report(false)
243                .show_default(true)
244                .interact()?;
245            if !confirm {
246                println!("{}", style("Canceling upload operation").bold().blue());
247                std::process::exit(0)
248            }
249        }
250
251        let pb = self.create_progress_bar(total_files, "Creating tarball");
252        pb.set_message("Initializing tarball creation");
253
254        // Process files
255        let mut files_processed = 0;
256        let walker = WalkBuilder::new(&project_path)
257            .hidden(false)
258            .git_ignore(true)
259            .git_global(true)
260            .git_exclude(true)
261            .build();
262
263        for entry in walker.filter_map(|e| e.ok()) {
264            if let Some(file_type) = entry.file_type() {
265                let entry_path = entry.path().to_path_buf();
266
267                // Convert the entry path to a relative path using path difference
268                let relative_path = pathdiff::diff_paths(&entry_path, &project_path)
269                    .ok_or_else(|| anyhow::anyhow!("Failed to compute relative path"))?;
270
271                // Skip root directory
272                if relative_path.as_os_str().is_empty() {
273                    continue;
274                }
275
276                if file_type.is_dir() {
277                    pb.set_message(format!("Adding directory: {}", relative_path.display()));
278
279                    let builder = std::sync::Arc::clone(&builder);
280                    let relative_path = relative_path.clone();
281
282                    task::spawn_blocking(move || -> Result<()> {
283                        let mut builder = builder.lock().unwrap();
284                        let mut header = tar::Header::new_ustar();
285                        header.set_entry_type(tar::EntryType::Directory);
286                        header.set_mode(0o755);
287                        header.set_size(0);
288                        builder.append_data(&mut header, relative_path, &[][..])?;
289                        Ok(())
290                    })
291                    .await??;
292                } else if file_type.is_file() {
293                    let file_contents = fs::read(&entry_path)
294                        .await
295                        .with_context(|| format!("Failed to read file: {:?}", entry_path))?;
296
297                    let builder = std::sync::Arc::clone(&builder);
298                    let relative_path_clone = relative_path.clone();
299
300                    task::spawn_blocking(move || -> Result<()> {
301                        let mut builder = builder.lock().unwrap();
302                        let mut header = tar::Header::new_ustar();
303                        header.set_size(file_contents.len() as u64);
304                        header.set_mode(0o644);
305                        builder.append_data(
306                            &mut header,
307                            relative_path_clone,
308                            &file_contents[..],
309                        )?;
310                        Ok(())
311                    })
312                    .await??;
313
314                    files_processed += 1;
315                    pb.set_position(files_processed);
316                    pb.set_message(format!("Adding file: {}", relative_path.display()));
317                }
318
319                tokio::time::sleep(Duration::from_millis(1)).await;
320            }
321        }
322
323        // Finalize the tarball
324        pb.set_message("Finalizing tarball");
325
326        task::spawn_blocking(move || -> Result<()> {
327            let mut builder = builder.lock().unwrap();
328            builder.finish()?;
329            Ok(())
330        })
331        .await??;
332
333        pb.finish_with_message("Tarball created successfully ✓");
334
335        Ok(tar_gz_path.to_string_lossy().into_owned())
336    }
337
338    async fn upload_tarball(
339        &self,
340        tarball_path: &str,
341        environment: &str,
342        name: &str,
343    ) -> Result<()> {
344        let path = PathBuf::from(tarball_path);
345        if !path.is_file() {
346            return Err(anyhow!("Path is not a file"));
347        }
348        let uuid = uuid::Uuid::new_v4();
349        let uuid_str = format!("u-{}", uuid.to_string());
350
351        // Use the base URL from the API client
352        let api_url = format!("{}/apps/{}/releases/{}/upload", 
353            self.api_client.base_url, name, uuid_str);
354
355        let file_content = fs::read(tarball_path).await?;
356
357        // Create the part with the correct field name "media" to match server expectations
358        let part = Part::bytes(file_content)
359            .file_name(name.to_string())
360            .mime_str("application/gzip")?;
361
362        // Use "media" as the field name to match the server's expected field
363        let form = Form::new()
364            .part("media", part)
365            .text("environment", environment.to_string());
366
367        let pb = self.create_progress_bar(100, "Uploading project");
368
369        // Use the API client's underlying client to send the request
370        let response = self.api_client.client
371            .post(&api_url)
372            .headers(self.api_client.headers.clone())
373            .multipart(form)
374            .send()
375            .await?;
376
377        if !response.status().is_success() {
378            pb.abandon_with_message("Upload failed!");
379            anyhow::bail!(
380                "Failed to upload tarball: {} - {}",
381                response.status(),
382                response
383                    .text()
384                    .await
385                    .unwrap_or_else(|_| "No error message".to_string())
386            );
387        }
388
389        pb.finish_with_message("Upload completed successfully ✓");
390        Ok(())
391    }
392    
393
394    async fn test_api_connection(&self) -> Result<()> {
395        let mut spinner = self.create_spinner("Testing API connection...");
396        
397        // Try to make a simple request to the API
398        match self.api_client.get::<serde_json::Value>("/health").await {
399            Ok(_) => {
400                spinner.stop_with_message("✅ Connection successful!".to_string());
401                Ok(())
402            },
403            Err(err) => {
404                spinner.stop_with_message(format!("❌ Connection failed: {}", err));
405                Err(err)
406            }
407        }
408    }
409}