Adds app endpoints for configuration and status.

This commit is contained in:
2025-10-22 23:17:52 +00:00
parent 5b7d2835e7
commit 005dc30aa5
7 changed files with 1128 additions and 105 deletions

View File

@@ -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}", api.AppsDelete).Methods("DELETE")
r.HandleFunc("/api/v1/instances/{name}/apps/{app}/status", api.AppsGetStatus).Methods("GET") 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 // 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.BackupAppStart).Methods("POST")
r.HandleFunc("/api/v1/instances/{name}/apps/{app}/backup", api.BackupAppList).Methods("GET") r.HandleFunc("/api/v1/instances/{name}/apps/{app}/backup", api.BackupAppList).Methods("GET")

View File

@@ -4,11 +4,16 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"net/http" "net/http"
"os"
"path/filepath"
"strconv"
"strings"
"github.com/gorilla/mux" "github.com/gorilla/mux"
"github.com/wild-cloud/wild-central/daemon/internal/apps" "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/operations"
"github.com/wild-cloud/wild-central/daemon/internal/tools"
) )
// AppsListAvailable lists all available apps // AppsListAvailable lists all available apps
@@ -186,3 +191,190 @@ func (api *API) AppsGetStatus(w http.ResponseWriter, r *http.Request) {
respondJSON(w, http.StatusOK, status) 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)
}

View File

@@ -2,6 +2,7 @@ package apps
import ( import (
"bytes" "bytes"
"encoding/json"
"fmt" "fmt"
"os" "os"
"os/exec" "os/exec"
@@ -34,9 +35,12 @@ type App struct {
Name string `json:"name" yaml:"name"` Name string `json:"name" yaml:"name"`
Description string `json:"description" yaml:"description"` Description string `json:"description" yaml:"description"`
Version string `json:"version" yaml:"version"` Version string `json:"version" yaml:"version"`
Category string `json:"category" yaml:"category"` Category string `json:"category,omitempty" yaml:"category,omitempty"`
Icon string `json:"icon,omitempty" yaml:"icon,omitempty"`
Dependencies []string `json:"dependencies" yaml:"dependencies"` Dependencies []string `json:"dependencies" yaml:"dependencies"`
Config map[string]string `json:"config,omitempty" yaml:"config,omitempty"` 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 // DeployedApp represents a deployed application instance
@@ -78,12 +82,30 @@ func (m *Manager) ListAvailable() ([]App, error) {
continue continue
} }
var app App var manifest AppManifest
if err := yaml.Unmarshal(data, &app); err != nil { if err := yaml.Unmarshal(data, &manifest); err != nil {
continue 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) 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) return nil, fmt.Errorf("failed to read app file: %w", err)
} }
var app App var manifest AppManifest
if err := yaml.Unmarshal(data, &app); err != nil { if err := yaml.Unmarshal(data, &manifest); err != nil {
return nil, fmt.Errorf("failed to parse app file: %w", err) return nil, fmt.Errorf("failed to parse app file: %w", err)
} }
app.Name = appName // Convert manifest to App struct
return &app, nil 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 // 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" { if yaml.Unmarshal(output, &ns) == nil && ns.Status.Phase == "Active" {
// Namespace is active - app is deployed // Namespace is active - app is deployed
app.Status = "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 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 ""
}

55
internal/apps/models.go Normal file
View File

@@ -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

View File

@@ -5,7 +5,6 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"io" "io"
"strings"
"time" "time"
"github.com/wild-cloud/wild-central/daemon/internal/contracts" "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) podName, err = kubectl.GetFirstPodName(namespace)
if err != nil { if err != nil {
// Check if it's because there are no pods // Check if it's because there are no pods
pods, _ := kubectl.GetPods(namespace) pods, _ := kubectl.GetPods(namespace, false)
if len(pods) == 0 { if len(pods) == 0 {
// Return empty logs response instead of error when no pods exist // Return empty logs response instead of error when no pods exist
return &contracts.ServiceLogsResponse{ return &contracts.ServiceLogsResponse{
@@ -61,7 +60,7 @@ func (m *Manager) GetLogs(instanceName, serviceName string, opts contracts.Servi
} }
} else { } else {
// Find pod with specified container // Find pod with specified container
pods, err := kubectl.GetPods(namespace) pods, err := kubectl.GetPods(namespace, false)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to list pods: %w", err) return nil, fmt.Errorf("failed to list pods: %w", err)
} }
@@ -87,15 +86,20 @@ func (m *Manager) GetLogs(instanceName, serviceName string, opts contracts.Servi
Tail: opts.Tail, Tail: opts.Tail,
Previous: opts.Previous, Previous: opts.Previous,
Since: opts.Since, Since: opts.Since,
SinceSeconds: 0,
} }
logs, err := kubectl.GetLogs(namespace, podName, logOpts) logEntries, err := kubectl.GetLogs(namespace, podName, logOpts)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to get logs: %w", err) return nil, fmt.Errorf("failed to get logs: %w", err)
} }
// 6. Parse logs into lines // 6. Convert structured logs to string lines
lines := strings.Split(strings.TrimSpace(logs), "\n") lines := make([]string, 0, len(logEntries))
for _, entry := range logEntries {
lines = append(lines, entry.Message)
}
truncated := false truncated := false
if len(lines) > opts.Tail { if len(lines) > opts.Tail {
lines = lines[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) podName, err = kubectl.GetFirstPodName(namespace)
if err != nil { if err != nil {
// Check if it's because there are no pods // Check if it's because there are no pods
pods, _ := kubectl.GetPods(namespace) pods, _ := kubectl.GetPods(namespace, false)
if len(pods) == 0 { if len(pods) == 0 {
// Send a message event indicating no pods // 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") 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] opts.Container = containers[0]
} }
} else { } else {
pods, err := kubectl.GetPods(namespace) pods, err := kubectl.GetPods(namespace, false)
if err != nil { if err != nil {
return fmt.Errorf("failed to list pods: %w", err) return fmt.Errorf("failed to list pods: %w", err)
} }

View File

@@ -72,7 +72,7 @@ func (m *Manager) GetDetailedStatus(instanceName, serviceName string) (*contract
} }
// 4. Get pod information // 4. Get pod information
podInfos, err := kubectl.GetPods(namespace) podInfos, err := kubectl.GetPods(namespace, false)
pods := make([]contracts.PodStatus, 0, len(podInfos)) pods := make([]contracts.PodStatus, 0, len(podInfos))
if err == nil { if err == nil {

View File

@@ -4,11 +4,13 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"os/exec" "os/exec"
"sort"
"strconv"
"strings" "strings"
"time" "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 { type Kubectl struct {
kubeconfigPath string 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 // DeploymentExists checks if a deployment exists in the specified namespace
func (k *Kubectl) DeploymentExists(name, namespace string) bool { func (k *Kubectl) DeploymentExists(name, namespace string) bool {
args := []string{ args := []string{
@@ -36,27 +147,9 @@ func (k *Kubectl) DeploymentExists(name, namespace string) bool {
return err == nil 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 // 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{ args := []string{
"get", "pods", "get", "pods",
"-n", namespace, "-n", namespace,
@@ -81,13 +174,35 @@ func (k *Kubectl) GetPods(namespace string) ([]PodInfo, error) {
} `json:"metadata"` } `json:"metadata"`
Spec struct { Spec struct {
NodeName string `json:"nodeName"` NodeName string `json:"nodeName"`
Containers []struct {
Name string `json:"name"`
Image string `json:"image"`
} `json:"containers"`
} `json:"spec"` } `json:"spec"`
Status struct { Status struct {
Phase string `json:"phase"` Phase string `json:"phase"`
PodIP string `json:"podIP"` 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 { ContainerStatuses []struct {
Name string `json:"name"`
Image string `json:"image"`
Ready bool `json:"ready"` Ready bool `json:"ready"`
RestartCount int `json:"restartCount"` 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:"containerStatuses"`
} `json:"status"` } `json:"status"`
} `json:"items"` } `json:"items"`
@@ -102,31 +217,117 @@ func (k *Kubectl) GetPods(namespace string) ([]PodInfo, error) {
// Calculate ready containers // Calculate ready containers
readyCount := 0 readyCount := 0
totalCount := len(pod.Status.ContainerStatuses) totalCount := len(pod.Status.ContainerStatuses)
restarts := 0 totalRestarts := 0
for _, cs := range pod.Status.ContainerStatuses { for _, cs := range pod.Status.ContainerStatuses {
if cs.Ready { if cs.Ready {
readyCount++ readyCount++
} }
restarts += cs.RestartCount totalRestarts += cs.RestartCount
} }
// Calculate age // Ensure status is never empty
age := formatAge(time.Since(pod.Metadata.CreationTimestamp)) status := pod.Status.Phase
if status == "" {
status = "Unknown"
}
pods = append(pods, PodInfo{ podInfo := PodInfo{
Name: pod.Metadata.Name, Name: pod.Metadata.Name,
Status: pod.Status.Phase, Status: status,
Ready: fmt.Sprintf("%d/%d", readyCount, totalCount), Ready: fmt.Sprintf("%d/%d", readyCount, totalCount),
Restarts: restarts, Restarts: totalRestarts,
Age: age, Age: formatAge(time.Since(pod.Metadata.CreationTimestamp)),
Node: pod.Spec.NodeName, Node: pod.Spec.NodeName,
IP: pod.Status.PodIP, 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 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 // GetDeployment retrieves deployment information
func (k *Kubectl) GetDeployment(name, namespace string) (*DeploymentInfo, error) { func (k *Kubectl) GetDeployment(name, namespace string) (*DeploymentInfo, error) {
args := []string{ args := []string{
@@ -169,25 +370,235 @@ func (k *Kubectl) GetDeployment(name, namespace string) (*DeploymentInfo, error)
}, nil }, nil
} }
// GetLogs retrieves logs from a pod // GetReplicas retrieves aggregated replica information for a namespace
func (k *Kubectl) GetLogs(namespace, podName string, opts LogOptions) (string, error) { func (k *Kubectl) GetReplicas(namespace string) (*ReplicaInfo, error) {
args := []string{ info := &ReplicaInfo{}
"logs", podName,
"-n", namespace, // 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 != "" { if opts.Container != "" {
args = append(args, "-c", opts.Container) args = append(args, "-c", opts.Container)
} }
if opts.Tail > 0 { 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 { if opts.Previous {
args = append(args, "--previous") args = append(args, "--previous")
} }
if opts.Since != "" {
args = append(args, "--since", opts.Since)
}
if k.kubeconfigPath != "" { if k.kubeconfigPath != "" {
args = append([]string{"--kubeconfig", k.kubeconfigPath}, args...) 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...) cmd := exec.Command("kubectl", args...)
output, err := cmd.Output() output, err := cmd.Output()
if err != nil { 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 for _, line := range lines {
type LogOptions struct { if line == "" {
Container string continue
Tail int }
Previous bool entries = append(entries, LogEntry{
Since string Timestamp: time.Now(), // Best effort - kubectl doesn't provide structured timestamps
Message: line,
Pod: podName,
})
}
return entries, nil
} }
// StreamLogs streams logs from a pod // StreamLogs streams logs from a pod
@@ -236,6 +653,8 @@ func (k *Kubectl) StreamLogs(namespace, podName string, opts LogOptions) (*exec.
return cmd, nil return cmd, nil
} }
// Helper Functions
// formatAge converts a duration to a human-readable age string // formatAge converts a duration to a human-readable age string
func formatAge(d time.Duration) string { func formatAge(d time.Duration) string {
if d < time.Minute { if d < time.Minute {
@@ -250,36 +669,71 @@ func formatAge(d time.Duration) string {
return fmt.Sprintf("%dd", int(d.Hours()/24)) return fmt.Sprintf("%dd", int(d.Hours()/24))
} }
// GetFirstPodName returns the name of the first pod in a namespace // parseResourceQuantity converts kubernetes resource quantities to millicores/bytes
func (k *Kubectl) GetFirstPodName(namespace string) (string, error) { func parseResourceQuantity(quantity string) int64 {
pods, err := k.GetPods(namespace) quantity = strings.TrimSpace(quantity)
if err != nil { if quantity == "" {
return "", err 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 // formatCPU formats millicores to human-readable format
func (k *Kubectl) GetPodContainers(namespace, podName string) ([]string, error) { func formatCPU(millicores int64) string {
args := []string{ if millicores == 0 {
"get", "pod", podName, return "0"
"-n", namespace,
"-o", "jsonpath={.spec.containers[*].name}",
} }
if millicores < 1000 {
if k.kubeconfigPath != "" { return fmt.Sprintf("%dm", millicores)
args = append([]string{"--kubeconfig", k.kubeconfigPath}, args...)
} }
return fmt.Sprintf("%.1f", float64(millicores)/1000.0)
cmd := exec.Command("kubectl", args...) }
output, err := cmd.Output()
if err != nil { // formatMemory formats bytes to human-readable format
return nil, fmt.Errorf("failed to get pod containers: %w", err) func formatMemory(bytes int64) string {
} if bytes == 0 {
return "0"
containerNames := strings.Fields(string(output)) }
return containerNames, nil
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])
} }