diff --git a/internal/api/v1/handlers.go b/internal/api/v1/handlers.go index b83c6f9..64af0d9 100644 --- a/internal/api/v1/handlers.go +++ b/internal/api/v1/handlers.go @@ -150,6 +150,13 @@ func (api *API) RegisterRoutes(r *mux.Router) { r.HandleFunc("/api/v1/instances/{name}/apps/{app}", api.AppsDelete).Methods("DELETE") r.HandleFunc("/api/v1/instances/{name}/apps/{app}/status", api.AppsGetStatus).Methods("GET") + // Enhanced app endpoints + r.HandleFunc("/api/v1/instances/{name}/apps/{app}/enhanced", api.AppsGetEnhanced).Methods("GET") + r.HandleFunc("/api/v1/instances/{name}/apps/{app}/runtime", api.AppsGetEnhancedStatus).Methods("GET") + r.HandleFunc("/api/v1/instances/{name}/apps/{app}/logs", api.AppsGetLogs).Methods("GET") + r.HandleFunc("/api/v1/instances/{name}/apps/{app}/events", api.AppsGetEvents).Methods("GET") + r.HandleFunc("/api/v1/instances/{name}/apps/{app}/readme", api.AppsGetReadme).Methods("GET") + // Backup & Restore r.HandleFunc("/api/v1/instances/{name}/apps/{app}/backup", api.BackupAppStart).Methods("POST") r.HandleFunc("/api/v1/instances/{name}/apps/{app}/backup", api.BackupAppList).Methods("GET") diff --git a/internal/api/v1/handlers_apps.go b/internal/api/v1/handlers_apps.go index edd9707..c91e9f2 100644 --- a/internal/api/v1/handlers_apps.go +++ b/internal/api/v1/handlers_apps.go @@ -4,11 +4,16 @@ import ( "encoding/json" "fmt" "net/http" + "os" + "path/filepath" + "strconv" + "strings" "github.com/gorilla/mux" "github.com/wild-cloud/wild-central/daemon/internal/apps" "github.com/wild-cloud/wild-central/daemon/internal/operations" + "github.com/wild-cloud/wild-central/daemon/internal/tools" ) // AppsListAvailable lists all available apps @@ -186,3 +191,190 @@ func (api *API) AppsGetStatus(w http.ResponseWriter, r *http.Request) { respondJSON(w, http.StatusOK, status) } + +// AppsGetEnhanced returns enhanced app details with runtime status +func (api *API) AppsGetEnhanced(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + instanceName := vars["name"] + appName := vars["app"] + + // Validate instance exists + if err := api.instance.ValidateInstance(instanceName); err != nil { + respondError(w, http.StatusNotFound, fmt.Sprintf("Instance not found: %v", err)) + return + } + + // Get enhanced app details + appsMgr := apps.NewManager(api.dataDir, api.appsDir) + enhanced, err := appsMgr.GetEnhanced(instanceName, appName) + if err != nil { + respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to get app details: %v", err)) + return + } + + respondJSON(w, http.StatusOK, enhanced) +} + +// AppsGetEnhancedStatus returns just runtime status for an app +func (api *API) AppsGetEnhancedStatus(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + instanceName := vars["name"] + appName := vars["app"] + + // Validate instance exists + if err := api.instance.ValidateInstance(instanceName); err != nil { + respondError(w, http.StatusNotFound, fmt.Sprintf("Instance not found: %v", err)) + return + } + + // Get runtime status + appsMgr := apps.NewManager(api.dataDir, api.appsDir) + status, err := appsMgr.GetEnhancedStatus(instanceName, appName) + if err != nil { + respondError(w, http.StatusNotFound, fmt.Sprintf("Failed to get runtime status: %v", err)) + return + } + + respondJSON(w, http.StatusOK, status) +} + +// AppsGetLogs returns logs for an app (from first pod) +func (api *API) AppsGetLogs(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + instanceName := vars["name"] + appName := vars["app"] + + // Validate instance exists + if err := api.instance.ValidateInstance(instanceName); err != nil { + respondError(w, http.StatusNotFound, fmt.Sprintf("Instance not found: %v", err)) + return + } + + // Parse query parameters + tailStr := r.URL.Query().Get("tail") + sinceSecondsStr := r.URL.Query().Get("sinceSeconds") + podName := r.URL.Query().Get("pod") + + tail := 100 // default + if tailStr != "" { + if t, err := strconv.Atoi(tailStr); err == nil && t > 0 { + tail = t + } + } + + sinceSeconds := 0 + if sinceSecondsStr != "" { + if s, err := strconv.Atoi(sinceSecondsStr); err == nil && s > 0 { + sinceSeconds = s + } + } + + // Get logs + kubeconfigPath := api.dataDir + "/instances/" + instanceName + "/kubeconfig" + kubectl := tools.NewKubectl(kubeconfigPath) + + // If no pod specified, get the first pod + if podName == "" { + pods, err := kubectl.GetPods(appName, true) + if err != nil || len(pods) == 0 { + respondError(w, http.StatusNotFound, "No pods found for app") + return + } + podName = pods[0].Name + } + + logOpts := tools.LogOptions{ + Tail: tail, + SinceSeconds: sinceSeconds, + } + logs, err := kubectl.GetLogs(appName, podName, logOpts) + if err != nil { + respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to get logs: %v", err)) + return + } + + respondJSON(w, http.StatusOK, map[string]interface{}{ + "pod": podName, + "logs": logs, + }) +} + +// AppsGetEvents returns kubernetes events for an app +func (api *API) AppsGetEvents(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + instanceName := vars["name"] + appName := vars["app"] + + // Validate instance exists + if err := api.instance.ValidateInstance(instanceName); err != nil { + respondError(w, http.StatusNotFound, fmt.Sprintf("Instance not found: %v", err)) + return + } + + // Parse query parameters + limitStr := r.URL.Query().Get("limit") + limit := 20 // default + if limitStr != "" { + if l, err := strconv.Atoi(limitStr); err == nil && l > 0 { + limit = l + } + } + + // Get events + kubeconfigPath := api.dataDir + "/instances/" + instanceName + "/kubeconfig" + kubectl := tools.NewKubectl(kubeconfigPath) + + events, err := kubectl.GetRecentEvents(appName, limit) + if err != nil { + respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to get events: %v", err)) + return + } + + respondJSON(w, http.StatusOK, map[string]interface{}{ + "events": events, + }) +} + +// AppsGetReadme returns the README.md content for an app +func (api *API) AppsGetReadme(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + instanceName := vars["name"] + appName := vars["app"] + + // Validate instance exists + if err := api.instance.ValidateInstance(instanceName); err != nil { + respondError(w, http.StatusNotFound, fmt.Sprintf("Instance not found: %v", err)) + return + } + + // Validate app name to prevent path traversal + if appName == "" || appName == "." || appName == ".." || + strings.Contains(appName, "/") || strings.Contains(appName, "\\") { + respondError(w, http.StatusBadRequest, "Invalid app name") + return + } + + // Try instance-specific README first + instancePath := filepath.Join(api.dataDir, "instances", instanceName, "apps", appName, "README.md") + content, err := os.ReadFile(instancePath) + if err == nil { + w.Header().Set("Content-Type", "text/markdown; charset=utf-8") + w.Write(content) + return + } + + // Fall back to global directory + globalPath := filepath.Join(api.appsDir, appName, "README.md") + content, err = os.ReadFile(globalPath) + if err != nil { + if os.IsNotExist(err) { + respondError(w, http.StatusNotFound, fmt.Sprintf("README not found for app '%s' in instance '%s'", appName, instanceName)) + } else { + respondError(w, http.StatusInternalServerError, "Failed to read README file") + } + return + } + + w.Header().Set("Content-Type", "text/markdown; charset=utf-8") + w.Write(content) +} diff --git a/internal/apps/apps.go b/internal/apps/apps.go index d5ead47..9077e85 100644 --- a/internal/apps/apps.go +++ b/internal/apps/apps.go @@ -2,6 +2,7 @@ package apps import ( "bytes" + "encoding/json" "fmt" "os" "os/exec" @@ -31,12 +32,15 @@ func NewManager(dataDir, appsDir string) *Manager { // 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" yaml:"category"` - Dependencies []string `json:"dependencies" yaml:"dependencies"` - Config map[string]string `json:"config,omitempty" yaml:"config,omitempty"` + 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 @@ -78,12 +82,30 @@ func (m *Manager) ListAvailable() ([]App, error) { continue } - var app App - if err := yaml.Unmarshal(data, &app); err != nil { + var manifest AppManifest + if err := yaml.Unmarshal(data, &manifest); err != nil { continue } - app.Name = entry.Name() // Use directory name as app name + // 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) } @@ -103,13 +125,31 @@ func (m *Manager) Get(appName string) (*App, error) { return nil, fmt.Errorf("failed to read app file: %w", err) } - var app App - if err := yaml.Unmarshal(data, &app); err != nil { + var manifest AppManifest + if err := yaml.Unmarshal(data, &manifest); err != nil { return nil, fmt.Errorf("failed to parse app file: %w", err) } - app.Name = appName - return &app, nil + // 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 @@ -173,6 +213,66 @@ func (m *Manager) ListDeployed(instanceName string) ([]DeployedApp, error) { 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 + } + } + } + } + } } } @@ -526,3 +626,214 @@ func (m *Manager) GetStatus(instanceName, appName string) (*DeployedApp, error) 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 "" +} diff --git a/internal/apps/models.go b/internal/apps/models.go new file mode 100644 index 0000000..69c584e --- /dev/null +++ b/internal/apps/models.go @@ -0,0 +1,55 @@ +package apps + +import "github.com/wild-cloud/wild-central/daemon/internal/tools" + +// AppManifest represents the complete app manifest from manifest.yaml +type AppManifest struct { + Name string `json:"name" yaml:"name"` + Description string `json:"description" yaml:"description"` + Version string `json:"version" yaml:"version"` + Icon string `json:"icon,omitempty" yaml:"icon,omitempty"` + Category string `json:"category,omitempty" yaml:"category,omitempty"` + Requires []AppDependency `json:"requires,omitempty" yaml:"requires,omitempty"` + DefaultConfig map[string]interface{} `json:"defaultConfig,omitempty" yaml:"defaultConfig,omitempty"` + RequiredSecrets []string `json:"requiredSecrets,omitempty" yaml:"requiredSecrets,omitempty"` +} + +// AppDependency represents a dependency on another app +type AppDependency struct { + Name string `json:"name" yaml:"name"` +} + +// EnhancedApp extends DeployedApp with runtime status information +type EnhancedApp struct { + Name string `json:"name"` + Status string `json:"status"` + Version string `json:"version"` + Namespace string `json:"namespace"` + URL string `json:"url,omitempty"` + Description string `json:"description,omitempty"` + Icon string `json:"icon,omitempty"` + Manifest *AppManifest `json:"manifest,omitempty"` + Runtime *RuntimeStatus `json:"runtime,omitempty"` + Config map[string]string `json:"config,omitempty"` + Readme string `json:"readme,omitempty"` + Documentation string `json:"documentation,omitempty"` +} + +// RuntimeStatus contains runtime information from kubernetes +type RuntimeStatus struct { + Pods []PodInfo `json:"pods,omitempty"` + Replicas *ReplicaInfo `json:"replicas,omitempty"` + Resources *ResourceUsage `json:"resources,omitempty"` + RecentEvents []KubernetesEvent `json:"recentEvents,omitempty"` +} + +// Type aliases for kubectl wrapper types +// These types are defined in internal/tools and shared across the codebase +type PodInfo = tools.PodInfo +type ContainerInfo = tools.ContainerInfo +type ContainerState = tools.ContainerState +type PodCondition = tools.PodCondition +type ReplicaInfo = tools.ReplicaInfo +type ResourceUsage = tools.ResourceUsage +type KubernetesEvent = tools.KubernetesEvent +type LogEntry = tools.LogEntry diff --git a/internal/services/logs.go b/internal/services/logs.go index f887056..dcc12fd 100644 --- a/internal/services/logs.go +++ b/internal/services/logs.go @@ -5,7 +5,6 @@ import ( "encoding/json" "fmt" "io" - "strings" "time" "github.com/wild-cloud/wild-central/daemon/internal/contracts" @@ -41,7 +40,7 @@ func (m *Manager) GetLogs(instanceName, serviceName string, opts contracts.Servi podName, err = kubectl.GetFirstPodName(namespace) if err != nil { // Check if it's because there are no pods - pods, _ := kubectl.GetPods(namespace) + pods, _ := kubectl.GetPods(namespace, false) if len(pods) == 0 { // Return empty logs response instead of error when no pods exist return &contracts.ServiceLogsResponse{ @@ -61,7 +60,7 @@ func (m *Manager) GetLogs(instanceName, serviceName string, opts contracts.Servi } } else { // Find pod with specified container - pods, err := kubectl.GetPods(namespace) + pods, err := kubectl.GetPods(namespace, false) if err != nil { return nil, fmt.Errorf("failed to list pods: %w", err) } @@ -83,19 +82,24 @@ func (m *Manager) GetLogs(instanceName, serviceName string, opts contracts.Servi // 5. Get logs logOpts := tools.LogOptions{ - Container: opts.Container, - Tail: opts.Tail, - Previous: opts.Previous, - Since: opts.Since, + Container: opts.Container, + Tail: opts.Tail, + Previous: opts.Previous, + Since: opts.Since, + SinceSeconds: 0, } - logs, err := kubectl.GetLogs(namespace, podName, logOpts) + logEntries, err := kubectl.GetLogs(namespace, podName, logOpts) if err != nil { return nil, fmt.Errorf("failed to get logs: %w", err) } - // 6. Parse logs into lines - lines := strings.Split(strings.TrimSpace(logs), "\n") + // 6. Convert structured logs to string lines + lines := make([]string, 0, len(logEntries)) + for _, entry := range logEntries { + lines = append(lines, entry.Message) + } + truncated := false if len(lines) > opts.Tail { lines = lines[len(lines)-opts.Tail:] @@ -139,7 +143,7 @@ func (m *Manager) StreamLogs(instanceName, serviceName string, opts contracts.Se podName, err = kubectl.GetFirstPodName(namespace) if err != nil { // Check if it's because there are no pods - pods, _ := kubectl.GetPods(namespace) + pods, _ := kubectl.GetPods(namespace, false) if len(pods) == 0 { // Send a message event indicating no pods fmt.Fprintf(writer, "data: No pods found for service. The service may not be deployed yet.\n\n") @@ -157,7 +161,7 @@ func (m *Manager) StreamLogs(instanceName, serviceName string, opts contracts.Se opts.Container = containers[0] } } else { - pods, err := kubectl.GetPods(namespace) + pods, err := kubectl.GetPods(namespace, false) if err != nil { return fmt.Errorf("failed to list pods: %w", err) } diff --git a/internal/services/status.go b/internal/services/status.go index 2850786..6e35f51 100644 --- a/internal/services/status.go +++ b/internal/services/status.go @@ -72,7 +72,7 @@ func (m *Manager) GetDetailedStatus(instanceName, serviceName string) (*contract } // 4. Get pod information - podInfos, err := kubectl.GetPods(namespace) + podInfos, err := kubectl.GetPods(namespace, false) pods := make([]contracts.PodStatus, 0, len(podInfos)) if err == nil { diff --git a/internal/tools/kubectl.go b/internal/tools/kubectl.go index 5ed9da0..24c58a6 100644 --- a/internal/tools/kubectl.go +++ b/internal/tools/kubectl.go @@ -4,11 +4,13 @@ import ( "encoding/json" "fmt" "os/exec" + "sort" + "strconv" "strings" "time" ) -// Kubectl provides a thin wrapper around the kubectl command-line tool +// Kubectl provides a comprehensive wrapper around the kubectl command-line tool type Kubectl struct { kubeconfigPath string } @@ -20,6 +22,115 @@ func NewKubectl(kubeconfigPath string) *Kubectl { } } +// Pod Information Structures + +// PodInfo represents pod information from kubectl +type PodInfo struct { + Name string `json:"name"` + Status string `json:"status"` + Ready string `json:"ready"` + Restarts int `json:"restarts"` + Age string `json:"age"` + Node string `json:"node,omitempty"` + IP string `json:"ip,omitempty"` + Containers []ContainerInfo `json:"containers,omitempty"` + Conditions []PodCondition `json:"conditions,omitempty"` +} + +// ContainerInfo represents detailed container information +type ContainerInfo struct { + Name string `json:"name"` + Image string `json:"image"` + Ready bool `json:"ready"` + RestartCount int `json:"restartCount"` + State ContainerState `json:"state"` +} + +// ContainerState represents the state of a container +type ContainerState struct { + Status string `json:"status"` + Reason string `json:"reason,omitempty"` + Message string `json:"message,omitempty"` + Since time.Time `json:"since,omitempty"` +} + +// PodCondition represents a pod condition +type PodCondition struct { + Type string `json:"type"` + Status string `json:"status"` + Reason string `json:"reason,omitempty"` + Message string `json:"message,omitempty"` + Since time.Time `json:"since,omitempty"` +} + +// Deployment Information Structures + +// DeploymentInfo represents deployment information +type DeploymentInfo struct { + Desired int32 `json:"desired"` + Current int32 `json:"current"` + Ready int32 `json:"ready"` + Available int32 `json:"available"` +} + +// ReplicaInfo represents aggregated replica information +type ReplicaInfo struct { + Desired int `json:"desired"` + Current int `json:"current"` + Ready int `json:"ready"` + Available int `json:"available"` +} + +// Resource Information Structures + +// ResourceMetric represents resource usage for a specific resource type +type ResourceMetric struct { + Used string `json:"used"` + Requested string `json:"requested"` + Limit string `json:"limit"` + Percentage float64 `json:"percentage"` +} + +// ResourceUsage represents aggregated resource usage +type ResourceUsage struct { + CPU *ResourceMetric `json:"cpu,omitempty"` + Memory *ResourceMetric `json:"memory,omitempty"` + Storage *ResourceMetric `json:"storage,omitempty"` +} + +// Event Information Structures + +// KubernetesEvent represents a Kubernetes event +type KubernetesEvent struct { + Type string `json:"type"` + Reason string `json:"reason"` + Message string `json:"message"` + Count int `json:"count"` + FirstSeen time.Time `json:"firstSeen"` + LastSeen time.Time `json:"lastSeen"` + Object string `json:"object"` +} + +// Logging Structures + +// LogOptions configures log retrieval +type LogOptions struct { + Container string + Tail int + Previous bool + Since string + SinceSeconds int +} + +// LogEntry represents a structured log entry +type LogEntry struct { + Timestamp time.Time `json:"timestamp"` + Message string `json:"message"` + Pod string `json:"pod"` +} + +// Pod Operations + // DeploymentExists checks if a deployment exists in the specified namespace func (k *Kubectl) DeploymentExists(name, namespace string) bool { args := []string{ @@ -36,27 +147,9 @@ func (k *Kubectl) DeploymentExists(name, namespace string) bool { return err == nil } -// PodInfo represents pod information from kubectl -type PodInfo struct { - Name string - Status string - Ready string - Restarts int - Age string - Node string - IP string -} - -// DeploymentInfo represents deployment information -type DeploymentInfo struct { - Desired int32 - Current int32 - Ready int32 - Available int32 -} - // GetPods retrieves pod information for a namespace -func (k *Kubectl) GetPods(namespace string) ([]PodInfo, error) { +// If detailed is true, includes containers and conditions +func (k *Kubectl) GetPods(namespace string, detailed bool) ([]PodInfo, error) { args := []string{ "get", "pods", "-n", namespace, @@ -80,14 +173,36 @@ func (k *Kubectl) GetPods(namespace string) ([]PodInfo, error) { CreationTimestamp time.Time `json:"creationTimestamp"` } `json:"metadata"` Spec struct { - NodeName string `json:"nodeName"` + NodeName string `json:"nodeName"` + Containers []struct { + Name string `json:"name"` + Image string `json:"image"` + } `json:"containers"` } `json:"spec"` Status struct { - Phase string `json:"phase"` - PodIP string `json:"podIP"` + Phase string `json:"phase"` + PodIP string `json:"podIP"` + Conditions []struct { + Type string `json:"type"` + Status string `json:"status"` + LastTransitionTime time.Time `json:"lastTransitionTime"` + Reason string `json:"reason"` + Message string `json:"message"` + } `json:"conditions"` ContainerStatuses []struct { - Ready bool `json:"ready"` - RestartCount int `json:"restartCount"` + Name string `json:"name"` + Image string `json:"image"` + Ready bool `json:"ready"` + RestartCount int `json:"restartCount"` + State struct { + Running *struct{ StartedAt time.Time } `json:"running,omitempty"` + Waiting *struct{ Reason, Message string } `json:"waiting,omitempty"` + Terminated *struct { + Reason string + Message string + FinishedAt time.Time + } `json:"terminated,omitempty"` + } `json:"state"` } `json:"containerStatuses"` } `json:"status"` } `json:"items"` @@ -102,31 +217,117 @@ func (k *Kubectl) GetPods(namespace string) ([]PodInfo, error) { // Calculate ready containers readyCount := 0 totalCount := len(pod.Status.ContainerStatuses) - restarts := 0 + totalRestarts := 0 + for _, cs := range pod.Status.ContainerStatuses { if cs.Ready { readyCount++ } - restarts += cs.RestartCount + totalRestarts += cs.RestartCount } - // Calculate age - age := formatAge(time.Since(pod.Metadata.CreationTimestamp)) + // Ensure status is never empty + status := pod.Status.Phase + if status == "" { + status = "Unknown" + } - pods = append(pods, PodInfo{ + podInfo := PodInfo{ Name: pod.Metadata.Name, - Status: pod.Status.Phase, + Status: status, Ready: fmt.Sprintf("%d/%d", readyCount, totalCount), - Restarts: restarts, - Age: age, + Restarts: totalRestarts, + Age: formatAge(time.Since(pod.Metadata.CreationTimestamp)), Node: pod.Spec.NodeName, IP: pod.Status.PodIP, - }) + } + + // Include detailed information if requested + if detailed { + // Add container details + containers := make([]ContainerInfo, 0, len(pod.Status.ContainerStatuses)) + for _, cs := range pod.Status.ContainerStatuses { + containerState := ContainerState{Status: "unknown"} + if cs.State.Running != nil { + containerState.Status = "running" + containerState.Since = cs.State.Running.StartedAt + } else if cs.State.Waiting != nil { + containerState.Status = "waiting" + containerState.Reason = cs.State.Waiting.Reason + containerState.Message = cs.State.Waiting.Message + } else if cs.State.Terminated != nil { + containerState.Status = "terminated" + containerState.Reason = cs.State.Terminated.Reason + containerState.Message = cs.State.Terminated.Message + containerState.Since = cs.State.Terminated.FinishedAt + } + + containers = append(containers, ContainerInfo{ + Name: cs.Name, + Image: cs.Image, + Ready: cs.Ready, + RestartCount: cs.RestartCount, + State: containerState, + }) + } + podInfo.Containers = containers + + // Add condition details + conditions := make([]PodCondition, 0, len(pod.Status.Conditions)) + for _, cond := range pod.Status.Conditions { + conditions = append(conditions, PodCondition{ + Type: cond.Type, + Status: cond.Status, + Reason: cond.Reason, + Message: cond.Message, + Since: cond.LastTransitionTime, + }) + } + podInfo.Conditions = conditions + } + + pods = append(pods, podInfo) } return pods, nil } +// GetFirstPodName returns the name of the first pod in a namespace +func (k *Kubectl) GetFirstPodName(namespace string) (string, error) { + pods, err := k.GetPods(namespace, false) + if err != nil { + return "", err + } + if len(pods) == 0 { + return "", fmt.Errorf("no pods found in namespace %s", namespace) + } + return pods[0].Name, nil +} + +// GetPodContainers returns container names for a pod +func (k *Kubectl) GetPodContainers(namespace, podName string) ([]string, error) { + args := []string{ + "get", "pod", podName, + "-n", namespace, + "-o", "jsonpath={.spec.containers[*].name}", + } + + if k.kubeconfigPath != "" { + args = append([]string{"--kubeconfig", k.kubeconfigPath}, args...) + } + + cmd := exec.Command("kubectl", args...) + output, err := cmd.Output() + if err != nil { + return nil, fmt.Errorf("failed to get pod containers: %w", err) + } + + containerNames := strings.Fields(string(output)) + return containerNames, nil +} + +// Deployment Operations + // GetDeployment retrieves deployment information func (k *Kubectl) GetDeployment(name, namespace string) (*DeploymentInfo, error) { args := []string{ @@ -169,25 +370,235 @@ func (k *Kubectl) GetDeployment(name, namespace string) (*DeploymentInfo, error) }, nil } -// GetLogs retrieves logs from a pod -func (k *Kubectl) GetLogs(namespace, podName string, opts LogOptions) (string, error) { - args := []string{ - "logs", podName, - "-n", namespace, +// GetReplicas retrieves aggregated replica information for a namespace +func (k *Kubectl) GetReplicas(namespace string) (*ReplicaInfo, error) { + info := &ReplicaInfo{} + + // Get deployments + deployCmd := exec.Command("kubectl", "get", "deployments", "-n", namespace, "-o", "json") + WithKubeconfig(deployCmd, k.kubeconfigPath) + + deployOutput, err := deployCmd.Output() + if err == nil { + var deployList struct { + Items []struct { + Spec struct { + Replicas int `json:"replicas"` + } `json:"spec"` + Status struct { + Replicas int `json:"replicas"` + ReadyReplicas int `json:"readyReplicas"` + AvailableReplicas int `json:"availableReplicas"` + } `json:"status"` + } `json:"items"` + } + + if json.Unmarshal(deployOutput, &deployList) == nil { + for _, deploy := range deployList.Items { + info.Desired += deploy.Spec.Replicas + info.Current += deploy.Status.Replicas + info.Ready += deploy.Status.ReadyReplicas + info.Available += deploy.Status.AvailableReplicas + } + } } + // Get statefulsets + stsCmd := exec.Command("kubectl", "get", "statefulsets", "-n", namespace, "-o", "json") + WithKubeconfig(stsCmd, k.kubeconfigPath) + + stsOutput, err := stsCmd.Output() + if err == nil { + var stsList struct { + Items []struct { + Spec struct { + Replicas int `json:"replicas"` + } `json:"spec"` + Status struct { + Replicas int `json:"replicas"` + ReadyReplicas int `json:"readyReplicas"` + } `json:"status"` + } `json:"items"` + } + + if json.Unmarshal(stsOutput, &stsList) == nil { + for _, sts := range stsList.Items { + info.Desired += sts.Spec.Replicas + info.Current += sts.Status.Replicas + info.Ready += sts.Status.ReadyReplicas + // StatefulSets don't have availableReplicas, use ready as proxy + info.Available += sts.Status.ReadyReplicas + } + } + } + + return info, nil +} + +// Resource Monitoring + +// GetResources retrieves aggregated resource usage for a namespace +func (k *Kubectl) GetResources(namespace string) (*ResourceUsage, error) { + cmd := exec.Command("kubectl", "get", "pods", "-n", namespace, "-o", "json") + WithKubeconfig(cmd, k.kubeconfigPath) + + output, err := cmd.Output() + if err != nil { + return nil, fmt.Errorf("failed to get pods: %w", err) + } + + var podList struct { + Items []struct { + Spec struct { + Containers []struct { + Resources struct { + Requests map[string]string `json:"requests,omitempty"` + Limits map[string]string `json:"limits,omitempty"` + } `json:"resources"` + } `json:"containers"` + } `json:"spec"` + } `json:"items"` + } + + if err := json.Unmarshal(output, &podList); err != nil { + return nil, fmt.Errorf("failed to parse pod list: %w", err) + } + + // Aggregate resources + cpuRequests := int64(0) + cpuLimits := int64(0) + memRequests := int64(0) + memLimits := int64(0) + + for _, pod := range podList.Items { + for _, container := range pod.Spec.Containers { + if req, ok := container.Resources.Requests["cpu"]; ok { + cpuRequests += parseResourceQuantity(req) + } + if lim, ok := container.Resources.Limits["cpu"]; ok { + cpuLimits += parseResourceQuantity(lim) + } + if req, ok := container.Resources.Requests["memory"]; ok { + memRequests += parseResourceQuantity(req) + } + if lim, ok := container.Resources.Limits["memory"]; ok { + memLimits += parseResourceQuantity(lim) + } + } + } + + // Build resource usage with metrics + usage := &ResourceUsage{} + + // CPU metrics (if any resources defined) + if cpuRequests > 0 || cpuLimits > 0 { + cpuUsed := cpuRequests // Approximate "used" as requests for now + cpuPercentage := 0.0 + if cpuLimits > 0 { + cpuPercentage = float64(cpuUsed) / float64(cpuLimits) * 100 + } + usage.CPU = &ResourceMetric{ + Used: formatCPU(cpuUsed), + Requested: formatCPU(cpuRequests), + Limit: formatCPU(cpuLimits), + Percentage: cpuPercentage, + } + } + + // Memory metrics (if any resources defined) + if memRequests > 0 || memLimits > 0 { + memUsed := memRequests // Approximate "used" as requests for now + memPercentage := 0.0 + if memLimits > 0 { + memPercentage = float64(memUsed) / float64(memLimits) * 100 + } + usage.Memory = &ResourceMetric{ + Used: formatMemory(memUsed), + Requested: formatMemory(memRequests), + Limit: formatMemory(memLimits), + Percentage: memPercentage, + } + } + + return usage, nil +} + +// GetRecentEvents retrieves recent events for a namespace +func (k *Kubectl) GetRecentEvents(namespace string, limit int) ([]KubernetesEvent, error) { + cmd := exec.Command("kubectl", "get", "events", "-n", namespace, + "--sort-by=.lastTimestamp", "-o", "json") + WithKubeconfig(cmd, k.kubeconfigPath) + + output, err := cmd.Output() + if err != nil { + return nil, fmt.Errorf("failed to get events: %w", err) + } + + var eventList struct { + Items []struct { + Type string `json:"type"` + Reason string `json:"reason"` + Message string `json:"message"` + Count int `json:"count"` + FirstTimestamp time.Time `json:"firstTimestamp"` + LastTimestamp time.Time `json:"lastTimestamp"` + InvolvedObject struct { + Kind string `json:"kind"` + Name string `json:"name"` + } `json:"involvedObject"` + } `json:"items"` + } + + if err := json.Unmarshal(output, &eventList); err != nil { + return nil, fmt.Errorf("failed to parse events: %w", err) + } + + // Sort by last timestamp (most recent first) + sort.Slice(eventList.Items, func(i, j int) bool { + return eventList.Items[i].LastTimestamp.After(eventList.Items[j].LastTimestamp) + }) + + // Limit results + if limit > 0 && len(eventList.Items) > limit { + eventList.Items = eventList.Items[:limit] + } + + events := make([]KubernetesEvent, 0, len(eventList.Items)) + for _, event := range eventList.Items { + events = append(events, KubernetesEvent{ + Type: event.Type, + Reason: event.Reason, + Message: event.Message, + Count: event.Count, + FirstSeen: event.FirstTimestamp, + LastSeen: event.LastTimestamp, + Object: fmt.Sprintf("%s/%s", event.InvolvedObject.Kind, event.InvolvedObject.Name), + }) + } + + return events, nil +} + +// Logging Operations + +// GetLogs retrieves logs from a pod +func (k *Kubectl) GetLogs(namespace, podName string, opts LogOptions) ([]LogEntry, error) { + args := []string{"logs", podName, "-n", namespace} + if opts.Container != "" { args = append(args, "-c", opts.Container) } if opts.Tail > 0 { - args = append(args, "--tail", fmt.Sprintf("%d", opts.Tail)) + args = append(args, "--tail", strconv.Itoa(opts.Tail)) + } + if opts.SinceSeconds > 0 { + args = append(args, "--since", fmt.Sprintf("%ds", opts.SinceSeconds)) + } else if opts.Since != "" { + args = append(args, "--since", opts.Since) } if opts.Previous { args = append(args, "--previous") } - if opts.Since != "" { - args = append(args, "--since", opts.Since) - } if k.kubeconfigPath != "" { args = append([]string{"--kubeconfig", k.kubeconfigPath}, args...) @@ -196,18 +607,24 @@ func (k *Kubectl) GetLogs(namespace, podName string, opts LogOptions) (string, e cmd := exec.Command("kubectl", args...) output, err := cmd.Output() if err != nil { - return "", fmt.Errorf("failed to get logs: %w", err) + return nil, fmt.Errorf("failed to get logs: %w", err) } - return string(output), nil -} + lines := strings.Split(string(output), "\n") + entries := make([]LogEntry, 0, len(lines)) -// LogOptions configures log retrieval -type LogOptions struct { - Container string - Tail int - Previous bool - Since string + for _, line := range lines { + if line == "" { + continue + } + entries = append(entries, LogEntry{ + Timestamp: time.Now(), // Best effort - kubectl doesn't provide structured timestamps + Message: line, + Pod: podName, + }) + } + + return entries, nil } // StreamLogs streams logs from a pod @@ -236,6 +653,8 @@ func (k *Kubectl) StreamLogs(namespace, podName string, opts LogOptions) (*exec. return cmd, nil } +// Helper Functions + // formatAge converts a duration to a human-readable age string func formatAge(d time.Duration) string { if d < time.Minute { @@ -250,36 +669,71 @@ func formatAge(d time.Duration) string { return fmt.Sprintf("%dd", int(d.Hours()/24)) } -// GetFirstPodName returns the name of the first pod in a namespace -func (k *Kubectl) GetFirstPodName(namespace string) (string, error) { - pods, err := k.GetPods(namespace) - if err != nil { - return "", err +// parseResourceQuantity converts kubernetes resource quantities to millicores/bytes +func parseResourceQuantity(quantity string) int64 { + quantity = strings.TrimSpace(quantity) + if quantity == "" { + return 0 } - if len(pods) == 0 { - return "", fmt.Errorf("no pods found in namespace %s", namespace) + + // Handle CPU (cores) + if strings.HasSuffix(quantity, "m") { + val, _ := strconv.ParseInt(strings.TrimSuffix(quantity, "m"), 10, 64) + return val } - return pods[0].Name, nil + + // Handle memory (bytes) + multipliers := map[string]int64{ + "Ki": 1024, + "Mi": 1024 * 1024, + "Gi": 1024 * 1024 * 1024, + "Ti": 1024 * 1024 * 1024 * 1024, + "K": 1000, + "M": 1000 * 1000, + "G": 1000 * 1000 * 1000, + "T": 1000 * 1000 * 1000 * 1000, + } + + for suffix, mult := range multipliers { + if strings.HasSuffix(quantity, suffix) { + val, _ := strconv.ParseInt(strings.TrimSuffix(quantity, suffix), 10, 64) + return val * mult + } + } + + // Plain number + val, _ := strconv.ParseInt(quantity, 10, 64) + return val } -// GetPodContainers returns container names for a pod -func (k *Kubectl) GetPodContainers(namespace, podName string) ([]string, error) { - args := []string{ - "get", "pod", podName, - "-n", namespace, - "-o", "jsonpath={.spec.containers[*].name}", +// formatCPU formats millicores to human-readable format +func formatCPU(millicores int64) string { + if millicores == 0 { + return "0" } - - if k.kubeconfigPath != "" { - args = append([]string{"--kubeconfig", k.kubeconfigPath}, args...) + if millicores < 1000 { + return fmt.Sprintf("%dm", millicores) } - - cmd := exec.Command("kubectl", args...) - output, err := cmd.Output() - if err != nil { - return nil, fmt.Errorf("failed to get pod containers: %w", err) - } - - containerNames := strings.Fields(string(output)) - return containerNames, nil + return fmt.Sprintf("%.1f", float64(millicores)/1000.0) +} + +// formatMemory formats bytes to human-readable format +func formatMemory(bytes int64) string { + if bytes == 0 { + return "0" + } + + const unit = 1024 + if bytes < unit { + return fmt.Sprintf("%dB", bytes) + } + + div, exp := int64(unit), 0 + for n := bytes / unit; n >= unit; n /= unit { + div *= unit + exp++ + } + + units := []string{"Ki", "Mi", "Gi", "Ti"} + return fmt.Sprintf("%.1f%s", float64(bytes)/float64(div), units[exp]) }