package apps import ( "bytes" "encoding/json" "fmt" "os" "os/exec" "path/filepath" "strings" "gopkg.in/yaml.v3" "github.com/wild-cloud/wild-central/daemon/internal/secrets" "github.com/wild-cloud/wild-central/daemon/internal/storage" "github.com/wild-cloud/wild-central/daemon/internal/tools" ) // Manager handles application lifecycle operations type Manager struct { dataDir string appsDir string // Path to apps directory in repo } // NewManager creates a new apps manager func NewManager(dataDir, appsDir string) *Manager { return &Manager{ dataDir: dataDir, appsDir: appsDir, } } // App represents an application type App struct { Name string `json:"name" yaml:"name"` Description string `json:"description" yaml:"description"` Version string `json:"version" yaml:"version"` Category string `json:"category,omitempty" yaml:"category,omitempty"` Icon string `json:"icon,omitempty" yaml:"icon,omitempty"` Dependencies []string `json:"dependencies" yaml:"dependencies"` Config map[string]string `json:"config,omitempty" yaml:"config,omitempty"` DefaultConfig map[string]interface{} `json:"defaultConfig,omitempty" yaml:"defaultConfig,omitempty"` RequiredSecrets []string `json:"requiredSecrets,omitempty" yaml:"requiredSecrets,omitempty"` } // DeployedApp represents a deployed application instance type DeployedApp struct { Name string `json:"name"` Status string `json:"status"` Version string `json:"version"` Namespace string `json:"namespace"` URL string `json:"url,omitempty"` } // ListAvailable lists all available apps from the apps directory func (m *Manager) ListAvailable() ([]App, error) { if m.appsDir == "" { return []App{}, fmt.Errorf("apps directory not configured") } // Read apps directory entries, err := os.ReadDir(m.appsDir) if err != nil { return []App{}, fmt.Errorf("failed to read apps directory: %w", err) } apps := []App{} for _, entry := range entries { if !entry.IsDir() { continue } // Check for manifest.yaml appFile := filepath.Join(m.appsDir, entry.Name(), "manifest.yaml") if !storage.FileExists(appFile) { continue } // Parse manifest.yaml data, err := os.ReadFile(appFile) if err != nil { continue } var manifest AppManifest if err := yaml.Unmarshal(data, &manifest); err != nil { continue } // Convert manifest to App struct app := App{ Name: entry.Name(), // Use directory name as app name Description: manifest.Description, Version: manifest.Version, Category: manifest.Category, Icon: manifest.Icon, DefaultConfig: manifest.DefaultConfig, RequiredSecrets: manifest.RequiredSecrets, } // Extract dependencies from Requires field if len(manifest.Requires) > 0 { app.Dependencies = make([]string, len(manifest.Requires)) for i, dep := range manifest.Requires { app.Dependencies[i] = dep.Name } } apps = append(apps, app) } return apps, nil } // Get returns details for a specific available app func (m *Manager) Get(appName string) (*App, error) { appFile := filepath.Join(m.appsDir, appName, "manifest.yaml") if !storage.FileExists(appFile) { return nil, fmt.Errorf("app %s not found", appName) } data, err := os.ReadFile(appFile) if err != nil { return nil, fmt.Errorf("failed to read app file: %w", err) } var manifest AppManifest if err := yaml.Unmarshal(data, &manifest); err != nil { return nil, fmt.Errorf("failed to parse app file: %w", err) } // Convert manifest to App struct app := &App{ Name: appName, Description: manifest.Description, Version: manifest.Version, Category: manifest.Category, Icon: manifest.Icon, DefaultConfig: manifest.DefaultConfig, RequiredSecrets: manifest.RequiredSecrets, } // Extract dependencies from Requires field if len(manifest.Requires) > 0 { app.Dependencies = make([]string, len(manifest.Requires)) for i, dep := range manifest.Requires { app.Dependencies[i] = dep.Name } } return app, nil } // ListDeployed lists deployed apps for an instance func (m *Manager) ListDeployed(instanceName string) ([]DeployedApp, error) { kubeconfigPath := tools.GetKubeconfigPath(m.dataDir, instanceName) instancePath := tools.GetInstancePath(m.dataDir, instanceName) appsDir := filepath.Join(instancePath, "apps") apps := []DeployedApp{} // Check if apps directory exists if !storage.FileExists(appsDir) { return apps, nil } // List all app directories entries, err := os.ReadDir(appsDir) if err != nil { return apps, fmt.Errorf("failed to read apps directory: %w", err) } // For each app directory, check if it's deployed in the cluster for _, entry := range entries { if !entry.IsDir() { continue } appName := entry.Name() // Initialize app with basic info app := DeployedApp{ Name: appName, Namespace: appName, Status: "added", // Default status: added but not deployed } // Try to get version from manifest manifestPath := filepath.Join(appsDir, appName, "manifest.yaml") if storage.FileExists(manifestPath) { manifestData, _ := os.ReadFile(manifestPath) var manifest struct { Version string `yaml:"version"` } if yaml.Unmarshal(manifestData, &manifest) == nil { app.Version = manifest.Version } } // Check if namespace exists in cluster checkCmd := exec.Command("kubectl", "get", "namespace", appName, "-o", "json") tools.WithKubeconfig(checkCmd, kubeconfigPath) output, err := checkCmd.CombinedOutput() if err == nil { // Namespace exists - parse status var ns struct { Status struct { Phase string `json:"phase"` } `json:"status"` } if yaml.Unmarshal(output, &ns) == nil && ns.Status.Phase == "Active" { // Namespace is active - app is deployed app.Status = "deployed" // Get ingress URL if available // Try Traefik IngressRoute first ingressCmd := exec.Command("kubectl", "get", "ingressroute", "-n", appName, "-o", "json") tools.WithKubeconfig(ingressCmd, kubeconfigPath) ingressOutput, err := ingressCmd.CombinedOutput() if err == nil { var ingressList struct { Items []struct { Spec struct { Routes []struct { Match string `json:"match"` } `json:"routes"` } `json:"spec"` } `json:"items"` } if json.Unmarshal(ingressOutput, &ingressList) == nil && len(ingressList.Items) > 0 { // Extract host from the first route match (format: Host(`example.com`)) if len(ingressList.Items[0].Spec.Routes) > 0 { match := ingressList.Items[0].Spec.Routes[0].Match // Parse Host(`domain.com`) format if strings.Contains(match, "Host(`") { start := strings.Index(match, "Host(`") + 6 end := strings.Index(match[start:], "`") if end > 0 { host := match[start : start+end] app.URL = "https://" + host } } } } } // If no IngressRoute, try standard Ingress if app.URL == "" { ingressCmd := exec.Command("kubectl", "get", "ingress", "-n", appName, "-o", "json") tools.WithKubeconfig(ingressCmd, kubeconfigPath) ingressOutput, err := ingressCmd.CombinedOutput() if err == nil { var ingressList struct { Items []struct { Spec struct { Rules []struct { Host string `json:"host"` } `json:"rules"` } `json:"spec"` } `json:"items"` } if json.Unmarshal(ingressOutput, &ingressList) == nil && len(ingressList.Items) > 0 { if len(ingressList.Items[0].Spec.Rules) > 0 { host := ingressList.Items[0].Spec.Rules[0].Host if host != "" { app.URL = "https://" + host } } } } } } } apps = append(apps, app) } return apps, nil } // Add adds an app to the instance configuration func (m *Manager) Add(instanceName, appName string, config map[string]string) error { // 1. Verify app exists manifestPath := filepath.Join(m.appsDir, appName, "manifest.yaml") if !storage.FileExists(manifestPath) { return fmt.Errorf("app %s not found at %s", appName, manifestPath) } instancePath := tools.GetInstancePath(m.dataDir, instanceName) configFile := tools.GetInstanceConfigPath(m.dataDir, instanceName) secretsFile := tools.GetInstanceSecretsPath(m.dataDir, instanceName) appDestDir := filepath.Join(instancePath, "apps", appName) // Check instance config exists if !storage.FileExists(configFile) { return fmt.Errorf("instance config not found: %s", instanceName) } // 2. Process manifest with gomplate tempManifest := filepath.Join(os.TempDir(), fmt.Sprintf("manifest-%s.yaml", appName)) defer os.Remove(tempManifest) gomplate := tools.NewGomplate() context := map[string]string{ ".": configFile, "secrets": secretsFile, } if err := gomplate.RenderWithContext(manifestPath, tempManifest, context); err != nil { return fmt.Errorf("failed to process manifest: %w", err) } // Parse processed manifest manifestData, err := os.ReadFile(tempManifest) if err != nil { return fmt.Errorf("failed to read processed manifest: %w", err) } var manifest struct { DefaultConfig map[string]interface{} `yaml:"defaultConfig"` RequiredSecrets []string `yaml:"requiredSecrets"` } if err := yaml.Unmarshal(manifestData, &manifest); err != nil { return fmt.Errorf("failed to parse manifest: %w", err) } // 3. Update configuration yq := tools.NewYQ() configLock := configFile + ".lock" if err := storage.WithLock(configLock, func() error { // Ensure apps section exists expr := fmt.Sprintf(".apps.%s = .apps.%s // {}", appName, appName) if _, err := yq.Exec("-i", expr, configFile); err != nil { return fmt.Errorf("failed to ensure apps section: %w", err) } // Merge defaultConfig (preserves existing values) if len(manifest.DefaultConfig) > 0 { for key, value := range manifest.DefaultConfig { keyPath := fmt.Sprintf(".apps.%s.%s", appName, key) // Only set if not already present existing, _ := yq.Get(configFile, keyPath) if existing == "" || existing == "null" { if err := yq.Set(configFile, keyPath, fmt.Sprintf("%v", value)); err != nil { return fmt.Errorf("failed to set config %s: %w", key, err) } } } } // Apply user-provided config overrides for key, value := range config { keyPath := fmt.Sprintf(".apps.%s.%s", appName, key) if err := yq.Set(configFile, keyPath, value); err != nil { return fmt.Errorf("failed to set config %s: %w", key, err) } } return nil }); err != nil { return err } // 4. Generate required secrets secretsMgr := secrets.NewManager() for _, secretKey := range manifest.RequiredSecrets { if _, err := secretsMgr.EnsureSecret(secretsFile, secretKey, secrets.DefaultSecretLength); err != nil { return fmt.Errorf("failed to ensure secret %s: %w", secretKey, err) } } // 5. Copy and compile app files if err := storage.EnsureDir(appDestDir, 0755); err != nil { return fmt.Errorf("failed to create app directory: %w", err) } // Copy source app directory sourceAppDir := filepath.Join(m.appsDir, appName) entries, err := os.ReadDir(sourceAppDir) if err != nil { return fmt.Errorf("failed to read app directory: %w", err) } for _, entry := range entries { if entry.IsDir() { // TODO: Handle subdirectories if needed continue } sourcePath := filepath.Join(sourceAppDir, entry.Name()) destPath := filepath.Join(appDestDir, entry.Name()) // Process with gomplate if err := gomplate.RenderWithContext(sourcePath, destPath, context); err != nil { return fmt.Errorf("failed to compile %s: %w", entry.Name(), err) } } return nil } // Deploy deploys an app to the cluster func (m *Manager) Deploy(instanceName, appName string) error { kubeconfigPath := tools.GetKubeconfigPath(m.dataDir, instanceName) instancePath := tools.GetInstancePath(m.dataDir, instanceName) secretsFile := tools.GetInstanceSecretsPath(m.dataDir, instanceName) // Get compiled app manifests from instance directory appDir := filepath.Join(instancePath, "apps", appName) if !storage.FileExists(appDir) { return fmt.Errorf("app %s not found in instance (run 'wild app add %s' first)", appName, appName) } // Create namespace if it doesn't exist namespaceCmd := exec.Command("kubectl", "create", "namespace", appName, "--dry-run=client", "-o", "yaml") tools.WithKubeconfig(namespaceCmd, kubeconfigPath) namespaceYaml, _ := namespaceCmd.CombinedOutput() applyNsCmd := exec.Command("kubectl", "apply", "-f", "-") applyNsCmd.Stdin = bytes.NewReader(namespaceYaml) tools.WithKubeconfig(applyNsCmd, kubeconfigPath) _, _ = applyNsCmd.CombinedOutput() // Ignore errors - namespace might already exist // Create Kubernetes secrets from secrets.yaml if storage.FileExists(secretsFile) { yq := tools.NewYQ() appSecretsPath := fmt.Sprintf(".apps.%s", appName) appSecretsJson, err := yq.Get(secretsFile, fmt.Sprintf("%s | @json", appSecretsPath)) if err == nil && appSecretsJson != "" && appSecretsJson != "null" { // Delete existing secret if it exists (to update it) deleteCmd := exec.Command("kubectl", "delete", "secret", fmt.Sprintf("%s-secrets", appName), "-n", appName, "--ignore-not-found") tools.WithKubeconfig(deleteCmd, kubeconfigPath) _, _ = deleteCmd.CombinedOutput() // Create secret from literals createSecretCmd := exec.Command("kubectl", "create", "secret", "generic", fmt.Sprintf("%s-secrets", appName), "-n", appName) // Parse secrets and add as literals var appSecrets map[string]string if err := yaml.Unmarshal([]byte(appSecretsJson), &appSecrets); err == nil { for key, value := range appSecrets { secretKey := fmt.Sprintf("apps.%s.%s", appName, key) createSecretCmd.Args = append(createSecretCmd.Args, fmt.Sprintf("--from-literal=%s=%s", secretKey, value)) } } tools.WithKubeconfig(createSecretCmd, kubeconfigPath) if output, err := createSecretCmd.CombinedOutput(); err != nil { return fmt.Errorf("failed to create secret: %w\nOutput: %s", err, string(output)) } } } // Apply manifests with kubectl using kustomize cmd := exec.Command("kubectl", "apply", "-k", appDir) tools.WithKubeconfig(cmd, kubeconfigPath) output, err := cmd.CombinedOutput() if err != nil { return fmt.Errorf("failed to deploy app: %w\nOutput: %s", err, string(output)) } return nil } // Delete removes an app from the cluster and configuration func (m *Manager) Delete(instanceName, appName string) error { kubeconfigPath := tools.GetKubeconfigPath(m.dataDir, instanceName) instancePath := tools.GetInstancePath(m.dataDir, instanceName) configFile := tools.GetInstanceConfigPath(m.dataDir, instanceName) secretsFile := tools.GetInstanceSecretsPath(m.dataDir, instanceName) // Get compiled app manifests from instance directory appDir := filepath.Join(instancePath, "apps", appName) if !storage.FileExists(appDir) { return fmt.Errorf("app %s not found in instance", appName) } // Delete namespace (this deletes ALL resources including deployments, services, secrets, etc.) deleteNsCmd := exec.Command("kubectl", "delete", "namespace", appName, "--ignore-not-found") tools.WithKubeconfig(deleteNsCmd, kubeconfigPath) output, err := deleteNsCmd.CombinedOutput() if err != nil { return fmt.Errorf("failed to delete namespace: %w\nOutput: %s", err, string(output)) } // Wait for namespace deletion to complete (timeout after 60s) waitCmd := exec.Command("kubectl", "wait", "--for=delete", "namespace", appName, "--timeout=60s") tools.WithKubeconfig(waitCmd, kubeconfigPath) _, _ = waitCmd.CombinedOutput() // Ignore errors - namespace might not exist // Delete local app configuration directory if err := os.RemoveAll(appDir); err != nil { return fmt.Errorf("failed to delete local app directory: %w", err) } // Remove app config from config.yaml yq := tools.NewYQ() configLock := configFile + ".lock" if storage.FileExists(configFile) { if err := storage.WithLock(configLock, func() error { return yq.Delete(configFile, fmt.Sprintf(".apps.%s", appName)) }); err != nil { return fmt.Errorf("failed to remove app config: %w", err) } } // Remove app secrets from secrets.yaml secretsMgr := secrets.NewManager() if storage.FileExists(secretsFile) { if err := secretsMgr.DeleteSecret(secretsFile, fmt.Sprintf("apps.%s", appName)); err != nil { // Don't fail if secret doesn't exist if !strings.Contains(err.Error(), "not found") { return fmt.Errorf("failed to remove app secrets: %w", err) } } } return nil } // GetStatus returns the status of a deployed app func (m *Manager) GetStatus(instanceName, appName string) (*DeployedApp, error) { kubeconfigPath := tools.GetKubeconfigPath(m.dataDir, instanceName) instancePath := tools.GetInstancePath(m.dataDir, instanceName) appDir := filepath.Join(instancePath, "apps", appName) app := &DeployedApp{ Name: appName, Status: "not-added", Namespace: appName, } // Check if app was added to instance if !storage.FileExists(appDir) { return app, nil } app.Status = "not-deployed" // Get version from manifest manifestPath := filepath.Join(appDir, "manifest.yaml") if storage.FileExists(manifestPath) { manifestData, _ := os.ReadFile(manifestPath) var manifest struct { Version string `yaml:"version"` } if yaml.Unmarshal(manifestData, &manifest) == nil { app.Version = manifest.Version } } // Check if namespace exists checkNsCmd := exec.Command("kubectl", "get", "namespace", appName, "-o", "json") tools.WithKubeconfig(checkNsCmd, kubeconfigPath) nsOutput, err := checkNsCmd.CombinedOutput() if err != nil { // Namespace doesn't exist - not deployed return app, nil } // Parse namespace to check if it's active var ns struct { Status struct { Phase string `json:"phase"` } `json:"status"` } if err := yaml.Unmarshal(nsOutput, &ns); err != nil || ns.Status.Phase != "Active" { return app, nil } // Namespace exists - check pod status podsCmd := exec.Command("kubectl", "get", "pods", "-n", appName, "-o", "json") tools.WithKubeconfig(podsCmd, kubeconfigPath) podsOutput, err := podsCmd.CombinedOutput() if err != nil { app.Status = "error" return app, nil } // Parse pods var podList struct { Items []struct { Status struct { Phase string `json:"phase"` ContainerStatuses []struct { Ready bool `json:"ready"` } `json:"containerStatuses"` } `json:"status"` } `json:"items"` } if err := yaml.Unmarshal(podsOutput, &podList); err != nil { app.Status = "error" return app, nil } if len(podList.Items) == 0 { app.Status = "no-pods" return app, nil } // Check pod status allRunning := true allReady := true for _, pod := range podList.Items { if pod.Status.Phase != "Running" { allRunning = false } for _, cs := range pod.Status.ContainerStatuses { if !cs.Ready { allReady = false } } } if allRunning && allReady { app.Status = "running" } else if allRunning { app.Status = "starting" } else { app.Status = "unhealthy" } return app, nil } // GetEnhanced returns enhanced app information with runtime status func (m *Manager) GetEnhanced(instanceName, appName string) (*EnhancedApp, error) { kubeconfigPath := tools.GetKubeconfigPath(m.dataDir, instanceName) instancePath := tools.GetInstancePath(m.dataDir, instanceName) configFile := tools.GetInstanceConfigPath(m.dataDir, instanceName) appDir := filepath.Join(instancePath, "apps", appName) enhanced := &EnhancedApp{ Name: appName, Status: "not-added", Namespace: appName, } // Check if app was added to instance if !storage.FileExists(appDir) { return enhanced, nil } enhanced.Status = "not-deployed" // Load manifest manifestPath := filepath.Join(appDir, "manifest.yaml") if storage.FileExists(manifestPath) { manifestData, _ := os.ReadFile(manifestPath) var manifest AppManifest if yaml.Unmarshal(manifestData, &manifest) == nil { enhanced.Version = manifest.Version enhanced.Description = manifest.Description enhanced.Icon = manifest.Icon enhanced.Manifest = &manifest } } // Note: README content is now served via dedicated /readme endpoint // No need to populate readme/documentation fields here // Load config yq := tools.NewYQ() configJSON, err := yq.Get(configFile, fmt.Sprintf(".apps.%s | @json", appName)) if err == nil && configJSON != "" && configJSON != "null" { var config map[string]string if json.Unmarshal([]byte(configJSON), &config) == nil { enhanced.Config = config } } // Check if namespace exists checkNsCmd := exec.Command("kubectl", "get", "namespace", appName, "-o", "json") tools.WithKubeconfig(checkNsCmd, kubeconfigPath) nsOutput, err := checkNsCmd.CombinedOutput() if err != nil { // Namespace doesn't exist - not deployed return enhanced, nil } // Parse namespace to check if it's active var ns struct { Status struct { Phase string `json:"phase"` } `json:"status"` } if err := json.Unmarshal(nsOutput, &ns); err != nil || ns.Status.Phase != "Active" { return enhanced, nil } enhanced.Status = "deployed" // Get URL (ingress) enhanced.URL = m.getAppURL(kubeconfigPath, appName) // Get runtime status runtime, err := m.getRuntimeStatus(kubeconfigPath, appName) if err == nil { enhanced.Runtime = runtime // Update status based on runtime if runtime.Pods != nil && len(runtime.Pods) > 0 { allRunning := true allReady := true for _, pod := range runtime.Pods { if pod.Status != "Running" { allRunning = false } // Check ready ratio parts := strings.Split(pod.Ready, "/") if len(parts) == 2 && parts[0] != parts[1] { allReady = false } } if allRunning && allReady { enhanced.Status = "running" } else if allRunning { enhanced.Status = "starting" } else { enhanced.Status = "unhealthy" } } } return enhanced, nil } // GetEnhancedStatus returns just the runtime status for an app func (m *Manager) GetEnhancedStatus(instanceName, appName string) (*RuntimeStatus, error) { kubeconfigPath := tools.GetKubeconfigPath(m.dataDir, instanceName) // Check if namespace exists checkNsCmd := exec.Command("kubectl", "get", "namespace", appName, "-o", "json") tools.WithKubeconfig(checkNsCmd, kubeconfigPath) if err := checkNsCmd.Run(); err != nil { return nil, fmt.Errorf("namespace not found or not deployed") } return m.getRuntimeStatus(kubeconfigPath, appName) } // getRuntimeStatus fetches runtime information from kubernetes func (m *Manager) getRuntimeStatus(kubeconfigPath, namespace string) (*RuntimeStatus, error) { kubectl := tools.NewKubectl(kubeconfigPath) runtime := &RuntimeStatus{} // Get pods (with detailed info for app status display) pods, err := kubectl.GetPods(namespace, true) if err == nil { runtime.Pods = pods } // Get replicas replicas, err := kubectl.GetReplicas(namespace) if err == nil && (replicas.Desired > 0 || replicas.Current > 0) { runtime.Replicas = replicas } // Get resources resources, err := kubectl.GetResources(namespace) if err == nil { runtime.Resources = resources } // Get recent events (last 10) events, err := kubectl.GetRecentEvents(namespace, 10) if err == nil { runtime.RecentEvents = events } return runtime, nil } // getAppURL extracts the ingress URL for an app func (m *Manager) getAppURL(kubeconfigPath, appName string) string { // Try Traefik IngressRoute first ingressCmd := exec.Command("kubectl", "get", "ingressroute", "-n", appName, "-o", "json") tools.WithKubeconfig(ingressCmd, kubeconfigPath) ingressOutput, err := ingressCmd.CombinedOutput() if err == nil { var ingressList struct { Items []struct { Spec struct { Routes []struct { Match string `json:"match"` } `json:"routes"` } `json:"spec"` } `json:"items"` } if json.Unmarshal(ingressOutput, &ingressList) == nil && len(ingressList.Items) > 0 { if len(ingressList.Items[0].Spec.Routes) > 0 { match := ingressList.Items[0].Spec.Routes[0].Match // Parse Host(`domain.com`) format if strings.Contains(match, "Host(`") { start := strings.Index(match, "Host(`") + 6 end := strings.Index(match[start:], "`") if end > 0 { host := match[start : start+end] return "https://" + host } } } } } // If no IngressRoute, try standard Ingress ingressCmd = exec.Command("kubectl", "get", "ingress", "-n", appName, "-o", "json") tools.WithKubeconfig(ingressCmd, kubeconfigPath) ingressOutput, err = ingressCmd.CombinedOutput() if err == nil { var ingressList struct { Items []struct { Spec struct { Rules []struct { Host string `json:"host"` } `json:"rules"` } `json:"spec"` } `json:"items"` } if json.Unmarshal(ingressOutput, &ingressList) == nil && len(ingressList.Items) > 0 { if len(ingressList.Items[0].Spec.Rules) > 0 { host := ingressList.Items[0].Spec.Rules[0].Host if host != "" { return "https://" + host } } } } return "" }