From 86677254994d94837e0a96cbe98030041022c58d Mon Sep 17 00:00:00 2001 From: secus Date: Tue, 16 Sep 2025 10:47:31 +0700 Subject: [PATCH 1/2] Create deployment, service, and ingress resources --- .../src/pages/NewDeploymentPage.tsx | 6 +- src/deployment/models.rs | 2 +- src/handlers/deployment.rs | 49 +-- src/jobs/deployment_worker.rs | 68 ++-- src/services/kubernetes.rs | 312 +++++++++++++++--- 5 files changed, 327 insertions(+), 110 deletions(-) diff --git a/apps/container-engine-frontend/src/pages/NewDeploymentPage.tsx b/apps/container-engine-frontend/src/pages/NewDeploymentPage.tsx index 8edc3fb..8c1b326 100644 --- a/apps/container-engine-frontend/src/pages/NewDeploymentPage.tsx +++ b/apps/container-engine-frontend/src/pages/NewDeploymentPage.tsx @@ -65,9 +65,9 @@ const NewDeploymentPage: React.FC = () => { replicas, }); setSuccess(`Deployment '${response.data.app_name}' created! URL: ${response.data.url}`); - if(response.data.id){ - navigate(`/deployments/${response.data.id}`); - } + if (response.data.id) { + navigate(`/deployments/${response.data.id}`); + } else return; } catch (err: any) { setError(err.response?.data || 'An unexpected error occurred.'); } finally { diff --git a/src/deployment/models.rs b/src/deployment/models.rs index 37cda03..db5df9a 100644 --- a/src/deployment/models.rs +++ b/src/deployment/models.rs @@ -92,7 +92,7 @@ pub struct DeploymentResponse { pub app_name: String, pub image: String, pub status: String, - pub url: String, + pub url: Option, pub created_at: DateTime, pub message: String, } diff --git a/src/handlers/deployment.rs b/src/handlers/deployment.rs index 445e283..d9324f3 100644 --- a/src/handlers/deployment.rs +++ b/src/handlers/deployment.rs @@ -49,9 +49,10 @@ pub async fn create_deployment( .health_check .map(|hc| serde_json::to_value(hc)) .transpose()?; + tracing::debug!("Inserting deployment record into database"); sqlx::query!( - r#" + r#" INSERT INTO deployments ( id, user_id, app_name, image, port, env_vars, replicas, resources, health_check, status, url, created_at, updated_at, @@ -59,28 +60,27 @@ pub async fn create_deployment( ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15) "#, - deployment_id, - user.user_id, - payload.app_name, - payload.image, - payload.port, - env_vars_json, - payload.replicas.unwrap_or(1), - resources, - health_check, - "pending", - url, - now, - now, - Option::>::None, // deployed_at - null initially - None:: // error_message - ) - .execute(&state.db.pool) - .await?; - - println!("Inserted deployment record into database"); - + deployment_id, + user.user_id, + payload.app_name, + payload.image, + payload.port, + env_vars_json, + payload.replicas.unwrap_or(1), + resources, + health_check, + "pending", + "".to_string(), // url will be set after deployment + now, + now, + Option::>::None, // deployed_at - null initially + None:: // error_message + ) + .execute(&state.db.pool) + .await?; + tracing::info!("Successfully inserted deployment record into database"); // TODO: Implement Kubernetes deployment logic here + tracing::debug!("Creating deployment job"); let job = DeploymentJob::new( deployment_id, user.user_id, @@ -92,7 +92,7 @@ pub async fn create_deployment( resources, health_check, ); - + tracing::debug!("Sending job to deployment queue"); if let Err(_) = state.deployment_sender.send(job).await { // Rollback the database record let _ = sqlx::query!("DELETE FROM deployments WHERE id = $1", deployment_id) @@ -101,6 +101,7 @@ pub async fn create_deployment( return Err(AppError::internal("Failed to queue deployment")); } + tracing::info!("Deployment system initialized successfully"); // For now, we'll just return the response @@ -109,7 +110,7 @@ pub async fn create_deployment( app_name: payload.app_name, image: payload.image, status: "pending".to_string(), - url, + url: None, created_at: now, message: "Deployment is being processed".to_string(), })) diff --git a/src/jobs/deployment_worker.rs b/src/jobs/deployment_worker.rs index 77955b9..fe625a9 100644 --- a/src/jobs/deployment_worker.rs +++ b/src/jobs/deployment_worker.rs @@ -53,14 +53,9 @@ impl DeploymentWorker { ); // Update status to "deploying" - if let Err(e) = Self::update_deployment_status( - &db_pool, - job.deployment_id, - "deploying", - None, - None, - ) - .await + if let Err(e) = + Self::update_deployment_status(&db_pool, job.deployment_id, "deploying", None, None) + .await { error!("Failed to update deployment status to deploying: {}", e); return; @@ -71,31 +66,48 @@ impl DeploymentWorker { Ok(_) => { info!("Successfully deployed to Kubernetes: {}", job.deployment_id); - // Poll for external IP - let public_ip = Self::wait_for_external_ip(&k8s_service, job.deployment_id).await; - let public_url = public_ip.as_ref().map(|ip| format!("http://{}:80", ip)); - - // Update deployment with success status - if let Err(e) = Self::update_deployment_status( - &db_pool, - job.deployment_id, - "running", - public_url.as_deref(), - None, - ) - .await - { - error!("Failed to update deployment status to running: {}", e); - } else { - info!("Deployment {} completed successfully", job.deployment_id); - if let Some(ip) = public_ip { - info!("Public IP assigned: {}", ip); + // Get the ingress URL after successful deployment + match k8s_service.get_ingress_url(&job.deployment_id).await { + Ok(ingress_url) => { + info!("Retrieved ingress URL: {:?}", ingress_url); + + // Update deployment with success status and URL + if let Err(e) = Self::update_deployment_status( + &db_pool, + job.deployment_id, + "running", + ingress_url.as_deref(), + None, + ) + .await + { + error!("Failed to update deployment status to running: {}", e); + } else { + info!("Deployment {} completed successfully", job.deployment_id); + if let Some(url) = ingress_url { + info!("Ingress URL available: {}", url); + } + } + } + Err(e) => { + error!("Failed to get ingress URL: {}", e); + // Still mark as running since deployment succeeded, just no URL yet + if let Err(e) = Self::update_deployment_status( + &db_pool, + job.deployment_id, + "running", + None, + Some("Deployment successful but ingress URL not ready yet"), + ) + .await + { + error!("Failed to update deployment status: {}", e); + } } } } Err(e) => { error!("Failed to deploy to Kubernetes: {}", e); - // Update deployment with failed status if let Err(db_err) = Self::update_deployment_status( &db_pool, diff --git a/src/services/kubernetes.rs b/src/services/kubernetes.rs index c9a8656..a4832a2 100644 --- a/src/services/kubernetes.rs +++ b/src/services/kubernetes.rs @@ -1,13 +1,17 @@ use k8s_openapi::api::apps::v1::{Deployment as K8sDeployment, DeploymentSpec}; use k8s_openapi::api::core::v1::{ - Service, ServiceSpec, ServicePort, Container, ContainerPort, - EnvVar, ResourceRequirements as K8sResourceRequirements, Probe, HTTPGetAction, + Container, ContainerPort, EnvVar, HTTPGetAction, Probe, + ResourceRequirements as K8sResourceRequirements, Service, ServicePort, ServiceSpec, }; -use kube::{Client, Api, api::PostParams}; -use std::collections::BTreeMap; -use uuid::Uuid; +use k8s_openapi::api::networking::v1::{ + HTTPIngressPath, HTTPIngressRuleValue, Ingress, IngressBackend, IngressRule, + IngressServiceBackend, IngressSpec, ServiceBackendPort, +}; +use kube::{api::PostParams, Api, Client}; use serde_json::Value; +use std::collections::BTreeMap; use tracing::{info, warn}; +use uuid::Uuid; use crate::jobs::deployment_job::DeploymentJob; use crate::AppError; @@ -20,25 +24,143 @@ pub struct KubernetesService { impl KubernetesService { pub async fn new(namespace: Option) -> Result { - let client = Client::try_default().await + let client = Client::try_default() + .await .map_err(|e| AppError::internal(&format!("Failed to create k8s client: {}", e)))?; - + let namespace = namespace.unwrap_or_else(|| "default".to_string()); - - info!("Initialized Kubernetes service for namespace: {}", namespace); + + info!( + "Initialized Kubernetes service for namespace: {}", + namespace + ); Ok(Self { client, namespace }) } + fn sanitize_app_name(&self, app_name: &str) -> String { + app_name + .replace(' ', "-") + .to_lowercase() + .chars() + .map(|c| { + if c.is_alphanumeric() || c == '-'|| c == '.' { + c + } else { + '-' + } + }) + .collect::() + } + // Create Ingress + async fn create_ingress(&self, job: &DeploymentJob) -> Result { + let ingress_name = self.generate_ingress_name(&job.deployment_id); + let service_name = self.generate_service_name(&job.deployment_id); + let host = format!("{}.local", self.sanitize_app_name(&job.app_name)); + + let ingress = Ingress { + metadata: k8s_openapi::apimachinery::pkg::apis::meta::v1::ObjectMeta { + name: Some(ingress_name.clone()), + namespace: Some(self.namespace.clone()), + annotations: Some(BTreeMap::from([ + ( + "nginx.ingress.kubernetes.io/rewrite-target".to_string(), + "/".to_string(), + ), + ( + "kubernetes.io/ingress.class".to_string(), + "nginx".to_string(), + ), + ])), + ..Default::default() + }, + spec: Some(IngressSpec { + rules: Some(vec![IngressRule { + host: Some(host.clone()), + http: Some(HTTPIngressRuleValue { + paths: vec![HTTPIngressPath { + path: Some("/".to_string()), + path_type: "Prefix".to_string(), + backend: IngressBackend { + service: Some(IngressServiceBackend { + name: service_name, + port: Some(ServiceBackendPort { + number: Some(80), + ..Default::default() + }), + }), + ..Default::default() + }, + }], + }), + }]), + ..Default::default() + }), + ..Default::default() + }; + + let ingresses: Api = Api::namespaced(self.client.clone(), &self.namespace); + + let result = ingresses + .create(&PostParams::default(), &ingress) + .await + .map_err(|e| AppError::internal(&format!("Failed to create ingress: {}", e)))?; + + info!("Created ingress: {} with host: {}", ingress_name, host); + Ok(result) + } + fn generate_ingress_name(&self, deployment_id: &Uuid) -> String { + format!( + "ing-{}", + deployment_id + .to_string() + .replace("-", "") + .chars() + .take(8) + .collect::() + ) + } + pub async fn get_ingress_url(&self, deployment_id: &Uuid) -> Result, AppError> { + let ingress_name = self.generate_ingress_name(deployment_id); + let ingresses: Api = Api::namespaced(self.client.clone(), &self.namespace); + match ingresses.get(&ingress_name).await { + Ok(ingress) => { + if let Some(spec) = ingress.spec { + if let Some(rules) = spec.rules { + if let Some(rule) = rules.first() { + if let Some(host) = &rule.host { + let minikube_ip = self.get_minikube_ip().await?; + return Ok(Some(format!("http://{}", host))); + } + } + } + } + Ok(None) + } + Err(e) => { + warn!("Failed to get ingress {}: {}", ingress_name, e); + Ok(None) + } + } + } + async fn get_minikube_ip(&self) -> Result { + // Implementation: call "minikube ip" command + // For now, return default + Ok("192.168.49.2".to_string()) + } pub async fn deploy_application(&self, job: &DeploymentJob) -> Result<(), AppError> { info!("Deploying application: {} to Kubernetes", job.app_name); - + // Create deployment first self.create_deployment(job).await?; - + // Create service self.create_service(job).await?; - - info!("Successfully created Kubernetes resources for: {}", job.app_name); + // Create ingress + self.create_ingress(job).await?; + info!( + "Successfully created Kubernetes resources for: {}", + job.app_name + ); Ok(()) } @@ -47,7 +169,8 @@ impl KubernetesService { let labels = self.generate_labels(job); // Environment variables - let env_vars: Vec = job.env_vars + let env_vars: Vec = job + .env_vars .iter() .map(|(k, v)| EnvVar { name: k.clone(), @@ -60,7 +183,8 @@ impl KubernetesService { let resources = self.parse_resource_requirements(&job.resources); // Health checks - let (readiness_probe, liveness_probe) = self.parse_health_probes(&job.health_check, job.port); + let (readiness_probe, liveness_probe) = + self.parse_health_probes(&job.health_check, job.port); let container = Container { name: "app".to_string(), @@ -71,7 +195,11 @@ impl KubernetesService { protocol: Some("TCP".to_string()), ..Default::default() }]), - env: if env_vars.is_empty() { None } else { Some(env_vars) }, + env: if env_vars.is_empty() { + None + } else { + Some(env_vars) + }, resources, readiness_probe, liveness_probe, @@ -109,10 +237,12 @@ impl KubernetesService { }; let deployments: Api = Api::namespaced(self.client.clone(), &self.namespace); - - let result = deployments.create(&PostParams::default(), &deployment).await + + let result = deployments + .create(&PostParams::default(), &deployment) + .await .map_err(|e| AppError::internal(&format!("Failed to create k8s deployment: {}", e)))?; - + info!("Created k8s deployment: {}", deployment_name); Ok(result) } @@ -120,7 +250,7 @@ impl KubernetesService { async fn create_service(&self, job: &DeploymentJob) -> Result { let service_name = self.generate_service_name(&job.deployment_id); let selector_labels = BTreeMap::from([ - ("app".to_string(), job.app_name.clone()), + ("app".to_string(), self.sanitize_app_name(&job.app_name)), ("deployment-id".to_string(), job.deployment_id.to_string()), ]); @@ -134,7 +264,9 @@ impl KubernetesService { selector: Some(selector_labels), ports: Some(vec![ServicePort { port: 80, - target_port: Some(k8s_openapi::apimachinery::pkg::util::intstr::IntOrString::Int(job.port)), + target_port: Some( + k8s_openapi::apimachinery::pkg::util::intstr::IntOrString::Int(job.port), + ), name: Some("http".to_string()), protocol: Some("TCP".to_string()), ..Default::default() @@ -146,18 +278,23 @@ impl KubernetesService { }; let services: Api = Api::namespaced(self.client.clone(), &self.namespace); - - let result = services.create(&PostParams::default(), &service).await + + let result = services + .create(&PostParams::default(), &service) + .await .map_err(|e| AppError::internal(&format!("Failed to create k8s service: {}", e)))?; - + info!("Created k8s service: {}", service_name); Ok(result) } - pub async fn get_service_external_ip(&self, deployment_id: &Uuid) -> Result, AppError> { + pub async fn get_service_external_ip( + &self, + deployment_id: &Uuid, + ) -> Result, AppError> { let service_name = self.generate_service_name(deployment_id); let services: Api = Api::namespaced(self.client.clone(), &self.namespace); - + match services.get(&service_name).await { Ok(service) => { if let Some(status) = service.status { @@ -181,13 +318,13 @@ impl KubernetesService { pub async fn get_deployment_status(&self, deployment_id: &Uuid) -> Result { let deployment_name = self.generate_deployment_name(deployment_id); let deployments: Api = Api::namespaced(self.client.clone(), &self.namespace); - + match deployments.get(&deployment_name).await { Ok(deployment) => { if let Some(status) = deployment.status { let ready_replicas = status.ready_replicas.unwrap_or(0); let replicas = status.replicas.unwrap_or(0); - + if ready_replicas == replicas && replicas > 0 { Ok("running".to_string()) } else { @@ -204,84 +341,151 @@ impl KubernetesService { pub async fn delete_deployment(&self, deployment_id: &Uuid) -> Result<(), AppError> { let deployment_name = self.generate_deployment_name(deployment_id); let service_name = self.generate_service_name(deployment_id); - + let ingress_name = self.generate_ingress_name(deployment_id); + let deployments: Api = Api::namespaced(self.client.clone(), &self.namespace); let services: Api = Api::namespaced(self.client.clone(), &self.namespace); - + let ingresses: Api = Api::namespaced(self.client.clone(), &self.namespace); + // Delete ingress + if let Err(e) = ingresses + .delete(&ingress_name, &kube::api::DeleteParams::default()) + .await + { + warn!("Failed to delete ingress {}: {}", ingress_name, e); + } else { + info!("Deleted ingress: {}", ingress_name); + } // Delete deployment - if let Err(e) = deployments.delete(&deployment_name, &kube::api::DeleteParams::default()).await { + if let Err(e) = deployments + .delete(&deployment_name, &kube::api::DeleteParams::default()) + .await + { warn!("Failed to delete deployment {}: {}", deployment_name, e); } else { info!("Deleted k8s deployment: {}", deployment_name); } - + // Delete service - if let Err(e) = services.delete(&service_name, &kube::api::DeleteParams::default()).await { + if let Err(e) = services + .delete(&service_name, &kube::api::DeleteParams::default()) + .await + { warn!("Failed to delete service {}: {}", service_name, e); } else { info!("Deleted k8s service: {}", service_name); } - + Ok(()) } // Helper methods fn generate_deployment_name(&self, deployment_id: &Uuid) -> String { - format!("app-{}", deployment_id.to_string().replace("-", "").chars().take(8).collect::()) + format!( + "app-{}", + deployment_id + .to_string() + .replace("-", "") + .chars() + .take(8) + .collect::() + ) } fn generate_service_name(&self, deployment_id: &Uuid) -> String { - format!("svc-{}", deployment_id.to_string().replace("-", "").chars().take(8).collect::()) + format!( + "svc-{}", + deployment_id + .to_string() + .replace("-", "") + .chars() + .take(8) + .collect::() + ) } fn generate_labels(&self, job: &DeploymentJob) -> BTreeMap { BTreeMap::from([ - ("app".to_string(), job.app_name.clone()), + ("app".to_string(), self.sanitize_app_name(&job.app_name)), // ← Sử dụng helper ("deployment-id".to_string(), job.deployment_id.to_string()), ("managed-by".to_string(), "deployment-service".to_string()), ]) } - fn parse_resource_requirements(&self, resources: &Option) -> Option { + fn parse_resource_requirements( + &self, + resources: &Option, + ) -> Option { if let Some(res) = resources { let mut limits = BTreeMap::new(); let mut requests = BTreeMap::new(); // Parse limits if let Some(cpu_limit) = res.get("cpu_limit").and_then(|v| v.as_str()) { - limits.insert("cpu".to_string(), - k8s_openapi::apimachinery::pkg::api::resource::Quantity(cpu_limit.to_string())); + limits.insert( + "cpu".to_string(), + k8s_openapi::apimachinery::pkg::api::resource::Quantity(cpu_limit.to_string()), + ); } if let Some(memory_limit) = res.get("memory_limit").and_then(|v| v.as_str()) { - limits.insert("memory".to_string(), - k8s_openapi::apimachinery::pkg::api::resource::Quantity(memory_limit.to_string())); + limits.insert( + "memory".to_string(), + k8s_openapi::apimachinery::pkg::api::resource::Quantity( + memory_limit.to_string(), + ), + ); } // Parse requests if let Some(cpu_request) = res.get("cpu_request").and_then(|v| v.as_str()) { - requests.insert("cpu".to_string(), - k8s_openapi::apimachinery::pkg::api::resource::Quantity(cpu_request.to_string())); + requests.insert( + "cpu".to_string(), + k8s_openapi::apimachinery::pkg::api::resource::Quantity( + cpu_request.to_string(), + ), + ); } if let Some(memory_request) = res.get("memory_request").and_then(|v| v.as_str()) { - requests.insert("memory".to_string(), - k8s_openapi::apimachinery::pkg::api::resource::Quantity(memory_request.to_string())); + requests.insert( + "memory".to_string(), + k8s_openapi::apimachinery::pkg::api::resource::Quantity( + memory_request.to_string(), + ), + ); } Some(K8sResourceRequirements { claims: None, - limits: if limits.is_empty() { None } else { Some(limits) }, - requests: if requests.is_empty() { None } else { Some(requests) }, + limits: if limits.is_empty() { + None + } else { + Some(limits) + }, + requests: if requests.is_empty() { + None + } else { + Some(requests) + }, }) } else { None } } - fn parse_health_probes(&self, health_check: &Option, default_port: i32) -> (Option, Option) { + fn parse_health_probes( + &self, + health_check: &Option, + default_port: i32, + ) -> (Option, Option) { if let Some(hc) = health_check { if let Some(path) = hc.get("path").and_then(|v| v.as_str()) { - let port = hc.get("port").and_then(|v| v.as_i64()).unwrap_or(default_port as i64) as i32; - let initial_delay = hc.get("initial_delay").and_then(|v| v.as_i64()).unwrap_or(30) as i32; + let port = hc + .get("port") + .and_then(|v| v.as_i64()) + .unwrap_or(default_port as i64) as i32; + let initial_delay = hc + .get("initial_delay") + .and_then(|v| v.as_i64()) + .unwrap_or(30) as i32; let period = hc.get("period").and_then(|v| v.as_i64()).unwrap_or(10) as i32; let timeout = hc.get("timeout").and_then(|v| v.as_i64()).unwrap_or(5) as i32; @@ -299,11 +503,11 @@ impl KubernetesService { success_threshold: Some(1), ..Default::default() }; - + return (Some(probe.clone()), Some(probe)); } } - + (None, None) } -} \ No newline at end of file +} From ae5af4868d937c37b6c78405a9296616ce815949 Mon Sep 17 00:00:00 2001 From: secus Date: Tue, 16 Sep 2025 11:48:38 +0700 Subject: [PATCH 2/2] Add open browser function and dev-no-browser command --- .env.example | 4 +- Cargo.toml | 2 +- apps/container-engine-frontend/src/App.tsx | 2 +- .../src/pages/DeploymentDetailPage.tsx | 1 - setup.sh | 139 +++++++++++++++++- src/main.rs | 83 +++++++++-- 6 files changed, 203 insertions(+), 28 deletions(-) diff --git a/.env.example b/.env.example index 2d7512d..00ff960 100644 --- a/.env.example +++ b/.env.example @@ -21,4 +21,6 @@ KUBERNETES_NAMESPACE=container-engine DOMAIN_SUFFIX=container-engine.app # Logging -RUST_LOG=container_engine=debug,tower_http=debug \ No newline at end of file +RUST_LOG=container_engine=debug,tower_http=debug +# Front end path +FRONTEND_PATH=./apps/container-engine-frontend/dist diff --git a/Cargo.toml b/Cargo.toml index 9a91e01..e78ea99 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,7 +7,7 @@ license = "MIT" repository = "https://github.com/ngocbd/Open-Container-Engine" [dependencies] - +open = "5" # Web framework axum = { version = "0.7", features = ["macros", "tracing"] } tokio = { version = "1.0", features = ["full"] } diff --git a/apps/container-engine-frontend/src/App.tsx b/apps/container-engine-frontend/src/App.tsx index 6f47945..07529be 100644 --- a/apps/container-engine-frontend/src/App.tsx +++ b/apps/container-engine-frontend/src/App.tsx @@ -10,7 +10,7 @@ import DeploymentDetailPage from './pages/DeploymentDetailPage'; import ApiKeysPage from './pages/ApiKeysPage'; import AccountSettingsPage from './pages/AccountSettingsPage'; import ProtectedRoute from './components/ProtectedRoute'; -import { ToastContainer, toast } from 'react-toastify'; +import { ToastContainer } from 'react-toastify'; import 'react-toastify/dist/ReactToastify.css'; function App() { diff --git a/apps/container-engine-frontend/src/pages/DeploymentDetailPage.tsx b/apps/container-engine-frontend/src/pages/DeploymentDetailPage.tsx index d730631..dd3f747 100644 --- a/apps/container-engine-frontend/src/pages/DeploymentDetailPage.tsx +++ b/apps/container-engine-frontend/src/pages/DeploymentDetailPage.tsx @@ -11,7 +11,6 @@ import { ClipboardDocumentListIcon, GlobeAltIcon, ArrowLeftIcon, - PlayIcon, StopIcon, ArrowPathIcon, ExclamationTriangleIcon, diff --git a/setup.sh b/setup.sh index 32225b7..96f3931 100755 --- a/setup.sh +++ b/setup.sh @@ -39,6 +39,74 @@ log_warning() { log_error() { echo -e "${RED}[ERROR]${NC} $1" } +# Function to open browser +open_browser() { + local url="${1:-http://localhost:3000}" + + log_info "Opening browser at: $url" + + # Detect OS and open browser accordingly + case "$OSTYPE" in + linux-gnu*) + # Linux + if command_exists xdg-open; then + xdg-open "$url" 2>/dev/null & + elif command_exists gnome-open; then + gnome-open "$url" 2>/dev/null & + elif command_exists kde-open; then + kde-open "$url" 2>/dev/null & + else + log_warning "Could not detect browser opener. Please open manually: $url" + return 1 + fi + ;; + darwin*) + # macOS + open "$url" 2>/dev/null & + ;; + msys*|cygwin*|mingw*) + # Windows (Git Bash/MinGW) + start "$url" 2>/dev/null & + ;; + *) + log_warning "Unknown OS type: $OSTYPE. Please open manually: $url" + return 1 + ;; + esac + + log_success "Browser opened successfully" +} + +# Function to wait for server and open browser +wait_and_open_browser() { + local port="${1:-3000}" + local path="${2:-}" + local max_retries=30 + local retry_count=0 + + log_info "Waiting for server to be ready..." + + while [ $retry_count -lt $max_retries ]; do + if curl -s -o /dev/null "http://localhost:$port/health" 2>/dev/null; then + log_success "Server is ready!" + open_browser "http://localhost:$port$path" + return 0 + fi + + sleep 1 + retry_count=$((retry_count + 1)) + + if [ $((retry_count % 5)) -eq 0 ]; then + log_info "Still waiting for server... ($retry_count/$max_retries)" + fi + done + + log_warning "Server did not become ready within 30 seconds" + return 1 +} +start_dev_no_browser() { + AUTO_OPEN_BROWSER=false start_dev +} # Show help show_help() { @@ -63,7 +131,9 @@ COMMANDS: db-reset Reset database and volumes migrate Run database migrations sqlx-prepare Prepare SQLx queries for offline compilation - dev Start development server + dev Start development server (auto-opens browser) + dev-no-browser Start development server without opening browser + open Open browser to development server build Build the project test Run tests format Format code @@ -73,12 +143,14 @@ COMMANDS: docker-up Start all services with Docker docker-down Stop all Docker services +ENVIRONMENT VARIABLES: + AUTO_OPEN_BROWSER Set to 'false' to disable auto-opening browser (default: true) + EXAMPLES: ./setup.sh setup # Full initial setup - ./setup.sh setup-k8s # Setup with Kubernetes support - ./setup.sh install-minikube # Install Minikube only - ./setup.sh dev # Start development server - ./setup.sh db-reset # Reset database + ./setup.sh dev # Start dev server with browser + AUTO_OPEN_BROWSER=false ./setup.sh dev # Start without browser + ./setup.sh open # Open browser to running server EOF } @@ -806,13 +878,54 @@ start_dev() { # Run migrations if needed export DATABASE_URL="$DATABASE_URL" + export REDIS_URL="$REDIS_URL" + if ! sqlx migrate info >/dev/null 2>&1; then run_migrations fi - log_info "Starting server at http://localhost:3000" - log_info "API documentation available at http://localhost:3000/api-docs/openapi.json" - cargo run + # Check if we should auto-open browser + local auto_open_browser="${AUTO_OPEN_BROWSER:-true}" + + # Start the server in background if auto-opening browser + if [ "$auto_open_browser" = "true" ]; then + log_info "Starting server with auto-browser opening..." + + # Start server in background + cargo run & + local server_pid=$! + + # Wait for server to be ready and open browser + if wait_and_open_browser 3000 "/auth"; then + log_info "Server is running at http://localhost:3000" + log_info "API documentation available at http://localhost:3000/swagger-ui" + + # Wait for the server process + wait $server_pid + else + # Kill the server if browser opening failed + kill $server_pid 2>/dev/null + log_error "Failed to start server properly" + return 1 + fi + else + # Normal server start without browser opening + log_info "Starting server at http://localhost:3000" + log_info "API documentation available at http://localhost:3000/swagger-ui" + cargo run + fi +} +# New command to just open browser +open_dev_browser() { + log_info "Opening development browser..." + + # Check if server is running + if curl -s -o /dev/null "http://localhost:3000/health" 2>/dev/null; then + open_browser "http://localhost:3000/auth" + else + log_warning "Server is not running. Start it first with: ./setup.sh dev" + return 1 + fi } build_project() { @@ -924,6 +1037,10 @@ case "${1:-help}" in setup) full_setup ;; + setup-k8s) + full_setup + install_minikube + ;; check) check_dependencies ;; @@ -960,6 +1077,12 @@ case "${1:-help}" in dev) start_dev ;; + dev-no-browser) + start_dev_no_browser + ;; + open) + open_dev_browser + ;; build) build_project ;; diff --git a/src/main.rs b/src/main.rs index 4d8d7ba..12f7cd4 100644 --- a/src/main.rs +++ b/src/main.rs @@ -7,7 +7,11 @@ use axum::{ use serde_json::{json, Value}; use std::net::SocketAddr; use tower::ServiceBuilder; -use tower_http::{cors::CorsLayer, trace::TraceLayer}; +use tower_http::{ + cors::CorsLayer, + services::{ServeDir, ServeFile}, + trace::TraceLayer, +}; use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; use utoipa::OpenApi; use utoipa_swagger_ui::SwaggerUi; @@ -18,9 +22,9 @@ mod database; mod deployment; mod error; mod handlers; -mod user; mod jobs; mod services; +mod user; use crate::jobs::{deployment_job::DeploymentJob, deployment_worker::DeploymentWorker}; use crate::services::kubernetes::KubernetesService; @@ -95,7 +99,6 @@ pub async fn setup_deployment_system( db_pool: sqlx::PgPool, k8s_namespace: Option, ) -> Result<(KubernetesService, mpsc::Sender), Box> { - // Initialize Kubernetes service let k8s_service = KubernetesService::new(k8s_namespace).await?; @@ -103,22 +106,40 @@ pub async fn setup_deployment_system( let (deployment_sender, deployment_receiver) = mpsc::channel::(100); // Start deployment worker - let worker = DeploymentWorker::new( - deployment_receiver, - k8s_service.clone(), - db_pool, - ); + let worker = DeploymentWorker::new(deployment_receiver, k8s_service.clone(), db_pool); tokio::spawn(async move { worker.start().await; }); tracing::info!("Deployment system initialized successfully"); - + Ok((k8s_service, deployment_sender)) } +async fn open_browser_on_startup(port: u16) { + tokio::spawn(async move { + // Đợi server khởi động + tokio::time::sleep(tokio::time::Duration::from_secs(1)).await; + + let url = format!("http://localhost:{}", port); + tracing::info!("Opening browser at: {}", url); + println!("\n🚀 Opening browser at: {}\n", url); + + match open::that(&url) { + Ok(()) => tracing::info!("Browser opened successfully"), + Err(e) => { + tracing::warn!("Failed to open browser automatically: {}", e); + println!( + "\n⚠️ Please open your browser manually and navigate to: {}\n", + url + ); + } + } + }); +} #[tokio::main] + async fn main() -> Result<(), Box> { // Initialize tracing tracing_subscriber::registry() @@ -149,17 +170,15 @@ async fn main() -> Result<(), Box> { .query_async::<_, String>(&mut redis_conn) .await?; tracing::info!("Redis connection established"); - // Setup deployment system - let (_k8s_service, deployment_sender) = setup_deployment_system( - db.pool.clone(), - config.kubernetes_namespace.clone(), - ).await?; + // Setup deployment system + let (_k8s_service, deployment_sender) = + setup_deployment_system(db.pool.clone(), config.kubernetes_namespace.clone()).await?; // Create app state let state = AppState { db, redis: redis_client, config: config.clone(), - deployment_sender + deployment_sender, }; // Build our application with routes @@ -168,7 +187,14 @@ async fn main() -> Result<(), Box> { // Run the server let addr = SocketAddr::from(([0, 0, 0, 0], config.port)); tracing::info!("Server listening on {}", addr); - + // Automatically open browser in development mode + let is_dev = std::env::var("ENVIRONMENT").unwrap_or_default() != "production"; + let auto_open = std::env::var("AUTO_OPEN_BROWSER") + .unwrap_or_else(|_| "true".to_string()) == "true"; + + if is_dev && auto_open { + open_browser_on_startup(config.port).await; + } let listener = tokio::net::TcpListener::bind(addr).await?; axum::serve(listener, app).await?; @@ -176,6 +202,29 @@ async fn main() -> Result<(), Box> { } fn create_app(state: AppState) -> Router { + let frontend_path = std::env::var("FRONTEND_PATH") + .unwrap_or_else(|_| "./apps/container-engine-frontend/dist".to_string()); + let frontend_exists = std::path::Path::new(&frontend_path).exists(); + if !frontend_exists { + tracing::error!("Frontend directory does not exist at: {}", frontend_path); + tracing::error!("Please build the frontend first:"); + tracing::error!(" cd apps/container-engine-frontend"); + tracing::error!(" npm install && npm run build"); + println!("\n❌ Frontend not found! Please build it first:"); + println!(" cd apps/container-engine-frontend"); + println!(" npm install && npm run build\n"); + } else { + tracing::info!("Serving frontend from: {}", frontend_path); + + // Kiểm tra file index.html + let index_exists = std::path::Path::new(&format!("{}/index.html", frontend_path)).exists(); + if !index_exists { + tracing::warn!("index.html not found in frontend directory"); + } + } + + let index_path = format!("{}/index.html", frontend_path); + let serve_dir = ServeDir::new(&frontend_path).not_found_service(ServeFile::new(&index_path)); Router::new() // Health check endpoint .route("/health", get(health_check)) @@ -265,6 +314,8 @@ fn create_app(state: AppState) -> Router { "/v1/deployments/:deployment_id/domains/:domain_id", axum::routing::delete(handlers::deployment::remove_domain), ) + // Serve static files + .fallback_service(serve_dir) // Add middleware .layer( ServiceBuilder::new()