diff --git a/BUILDING_WILD_API.md b/BUILDING_WILD_API.md index 0164188..619daf3 100644 --- a/BUILDING_WILD_API.md +++ b/BUILDING_WILD_API.md @@ -89,9 +89,19 @@ func (api *API) UtilitiesDashboardToken(w http.ResponseWriter, r *http.Request) } ``` -#### Using Kubeconfig with kubectl/talosctl +#### Key Principles -When making kubectl or talosctl calls for a specific instance, use the `tools.WithKubeconfig()` helper to set the KUBECONFIG environment variable: +1. **Instance name in URL**: Always include instance name as a path parameter (`{name}`) +2. **Extract from mux.Vars()**: Get instance name from `mux.Vars(r)["name"]`, not from context +3. **Validate instance**: Always validate the instance exists before operations +4. **Use path helpers**: Use `tools.GetKubeconfigPath()`, `tools.GetInstanceConfigPath()`, etc. instead of inline `filepath.Join()` constructions +5. **Stateless handlers**: Handlers should not depend on session state or current context + +### kubectl and talosctl Commands + +When making kubectl or talosctl calls for a specific instance, always use the `tools` package helpers to set the correct context. + +#### Using kubectl with Instance Kubeconfig ```go // In utilities.go or similar @@ -108,11 +118,25 @@ func GetDashboardToken(kubeconfigPath string) (*DashboardToken, error) { } ``` +#### Using talosctl with Instance Talosconfig + +```go +// In cluster operations +func GetClusterHealth(talosconfigPath string, nodeIP string) error { + cmd := exec.Command("talosctl", "health", "--nodes", nodeIP) + tools.WithTalosconfig(cmd, talosconfigPath) + output, err := cmd.Output() + if err != nil { + return fmt.Errorf("failed to check health: %w", err) + } + // Process output... + return nil +} +``` + #### Key Principles -1. **Instance name in URL**: Always include instance name as a path parameter (`{name}`) -2. **Extract from mux.Vars()**: Get instance name from `mux.Vars(r)["name"]`, not from context -3. **Validate instance**: Always validate the instance exists before operations -4. **Use path helpers**: Use `tools.GetKubeconfigPath()`, `tools.GetInstanceConfigPath()`, etc. instead of inline `filepath.Join()` constructions -5. **Stateless handlers**: Handlers should not depend on session state or current context -6. **Use tools helpers**: Use `tools.WithKubeconfig()` for kubectl/talosctl commands +1. **Use tools helpers**: Always use `tools.WithKubeconfig()` or `tools.WithTalosconfig()` instead of manually setting environment variables +2. **Get paths from tools package**: Use `tools.GetKubeconfigPath()` or `tools.GetTalosconfigPath()` to construct config paths +3. **One config per command**: Each exec.Command should have its config set via the appropriate helper +4. **Error handling**: Always check for command execution errors and provide context diff --git a/internal/api/v1/handlers.go b/internal/api/v1/handlers.go index ab800f7..b83c6f9 100644 --- a/internal/api/v1/handlers.go +++ b/internal/api/v1/handlers.go @@ -108,9 +108,9 @@ func (api *API) RegisterRoutes(r *mux.Router) { // Operations r.HandleFunc("/api/v1/instances/{name}/operations", api.OperationList).Methods("GET") - r.HandleFunc("/api/v1/operations/{id}", api.OperationGet).Methods("GET") - r.HandleFunc("/api/v1/operations/{id}/stream", api.OperationStream).Methods("GET") - r.HandleFunc("/api/v1/operations/{id}/cancel", api.OperationCancel).Methods("POST") + r.HandleFunc("/api/v1/instances/{name}/operations/{id}", api.OperationGet).Methods("GET") + r.HandleFunc("/api/v1/instances/{name}/operations/{id}/stream", api.OperationStream).Methods("GET") + r.HandleFunc("/api/v1/instances/{name}/operations/{id}/cancel", api.OperationCancel).Methods("POST") // Cluster operations r.HandleFunc("/api/v1/instances/{name}/cluster/config/generate", api.ClusterGenerateConfig).Methods("POST") @@ -156,13 +156,12 @@ func (api *API) RegisterRoutes(r *mux.Router) { r.HandleFunc("/api/v1/instances/{name}/apps/{app}/restore", api.BackupAppRestore).Methods("POST") // Utilities - r.HandleFunc("/api/v1/utilities/health", api.UtilitiesHealth).Methods("GET") r.HandleFunc("/api/v1/instances/{name}/utilities/health", api.InstanceUtilitiesHealth).Methods("GET") r.HandleFunc("/api/v1/instances/{name}/utilities/dashboard/token", api.UtilitiesDashboardToken).Methods("GET") - r.HandleFunc("/api/v1/utilities/nodes/ips", api.UtilitiesNodeIPs).Methods("GET") - r.HandleFunc("/api/v1/utilities/controlplane/ip", api.UtilitiesControlPlaneIP).Methods("GET") - r.HandleFunc("/api/v1/utilities/secrets/{secret}/copy", api.UtilitiesSecretCopy).Methods("POST") - r.HandleFunc("/api/v1/utilities/version", api.UtilitiesVersion).Methods("GET") + r.HandleFunc("/api/v1/instances/{name}/utilities/nodes/ips", api.UtilitiesNodeIPs).Methods("GET") + r.HandleFunc("/api/v1/instances/{name}/utilities/controlplane/ip", api.UtilitiesControlPlaneIP).Methods("GET") + r.HandleFunc("/api/v1/instances/{name}/utilities/secrets/{secret}/copy", api.UtilitiesSecretCopy).Methods("POST") + r.HandleFunc("/api/v1/instances/{name}/utilities/version", api.UtilitiesVersion).Methods("GET") // dnsmasq management r.HandleFunc("/api/v1/dnsmasq/status", api.DnsmasqStatus).Methods("GET") diff --git a/internal/api/v1/handlers_operations.go b/internal/api/v1/handlers_operations.go index f17853b..2553e22 100644 --- a/internal/api/v1/handlers_operations.go +++ b/internal/api/v1/handlers_operations.go @@ -17,12 +17,12 @@ import ( // OperationGet returns operation status func (api *API) OperationGet(w http.ResponseWriter, r *http.Request) { vars := mux.Vars(r) + instanceName := vars["name"] opID := vars["id"] - // Extract instance name from query param or header - instanceName := r.URL.Query().Get("instance") - if instanceName == "" { - respondError(w, http.StatusBadRequest, "instance parameter is required") + // Validate instance exists + if err := api.instance.ValidateInstance(instanceName); err != nil { + respondError(w, http.StatusNotFound, fmt.Sprintf("Instance not found: %v", err)) return } @@ -64,12 +64,12 @@ func (api *API) OperationList(w http.ResponseWriter, r *http.Request) { // OperationCancel cancels an operation func (api *API) OperationCancel(w http.ResponseWriter, r *http.Request) { vars := mux.Vars(r) + instanceName := vars["name"] opID := vars["id"] - // Extract instance name from query param - instanceName := r.URL.Query().Get("instance") - if instanceName == "" { - respondError(w, http.StatusBadRequest, "instance parameter is required") + // Validate instance exists + if err := api.instance.ValidateInstance(instanceName); err != nil { + respondError(w, http.StatusNotFound, fmt.Sprintf("Instance not found: %v", err)) return } @@ -89,12 +89,12 @@ func (api *API) OperationCancel(w http.ResponseWriter, r *http.Request) { // OperationStream streams operation output via Server-Sent Events (SSE) func (api *API) OperationStream(w http.ResponseWriter, r *http.Request) { vars := mux.Vars(r) + instanceName := vars["name"] opID := vars["id"] - // Extract instance name from query param - instanceName := r.URL.Query().Get("instance") - if instanceName == "" { - respondError(w, http.StatusBadRequest, "instance parameter is required") + // Validate instance exists + if err := api.instance.ValidateInstance(instanceName); err != nil { + respondError(w, http.StatusNotFound, fmt.Sprintf("Instance not found: %v", err)) return } diff --git a/internal/api/v1/handlers_utilities.go b/internal/api/v1/handlers_utilities.go index 8ca9c56..8896b90 100644 --- a/internal/api/v1/handlers_utilities.go +++ b/internal/api/v1/handlers_utilities.go @@ -10,20 +10,6 @@ import ( "github.com/wild-cloud/wild-central/daemon/internal/utilities" ) -// UtilitiesHealth returns cluster health status (legacy, no instance context) -func (api *API) UtilitiesHealth(w http.ResponseWriter, r *http.Request) { - status, err := utilities.GetClusterHealth("") - if err != nil { - respondError(w, http.StatusInternalServerError, "Failed to get cluster health") - return - } - - respondJSON(w, http.StatusOK, map[string]interface{}{ - "success": true, - "data": status, - }) -} - // InstanceUtilitiesHealth returns cluster health status for a specific instance func (api *API) InstanceUtilitiesHealth(w http.ResponseWriter, r *http.Request) { vars := mux.Vars(r) @@ -82,7 +68,19 @@ func (api *API) UtilitiesDashboardToken(w http.ResponseWriter, r *http.Request) // UtilitiesNodeIPs returns IP addresses for all cluster nodes func (api *API) UtilitiesNodeIPs(w http.ResponseWriter, r *http.Request) { - nodes, err := utilities.GetNodeIPs() + vars := mux.Vars(r) + instanceName := vars["name"] + + // Validate instance exists + if err := api.instance.ValidateInstance(instanceName); err != nil { + respondError(w, http.StatusNotFound, fmt.Sprintf("Instance not found: %v", err)) + return + } + + // Get kubeconfig path for this instance + kubeconfigPath := tools.GetKubeconfigPath(api.dataDir, instanceName) + + nodes, err := utilities.GetNodeIPs(kubeconfigPath) if err != nil { respondError(w, http.StatusInternalServerError, "Failed to get node IPs") return @@ -98,7 +96,19 @@ func (api *API) UtilitiesNodeIPs(w http.ResponseWriter, r *http.Request) { // UtilitiesControlPlaneIP returns the control plane IP func (api *API) UtilitiesControlPlaneIP(w http.ResponseWriter, r *http.Request) { - ip, err := utilities.GetControlPlaneIP() + vars := mux.Vars(r) + instanceName := vars["name"] + + // Validate instance exists + if err := api.instance.ValidateInstance(instanceName); err != nil { + respondError(w, http.StatusNotFound, fmt.Sprintf("Instance not found: %v", err)) + return + } + + // Get kubeconfig path for this instance + kubeconfigPath := tools.GetKubeconfigPath(api.dataDir, instanceName) + + ip, err := utilities.GetControlPlaneIP(kubeconfigPath) if err != nil { respondError(w, http.StatusInternalServerError, "Failed to get control plane IP") return @@ -115,8 +125,15 @@ func (api *API) UtilitiesControlPlaneIP(w http.ResponseWriter, r *http.Request) // UtilitiesSecretCopy copies a secret between namespaces func (api *API) UtilitiesSecretCopy(w http.ResponseWriter, r *http.Request) { vars := mux.Vars(r) + instanceName := vars["name"] secretName := vars["secret"] + // Validate instance exists + if err := api.instance.ValidateInstance(instanceName); err != nil { + respondError(w, http.StatusNotFound, fmt.Sprintf("Instance not found: %v", err)) + return + } + var req struct { SourceNamespace string `json:"source_namespace"` DestinationNamespace string `json:"destination_namespace"` @@ -132,7 +149,10 @@ func (api *API) UtilitiesSecretCopy(w http.ResponseWriter, r *http.Request) { return } - if err := utilities.CopySecretBetweenNamespaces(secretName, req.SourceNamespace, req.DestinationNamespace); err != nil { + // Get kubeconfig path for this instance + kubeconfigPath := tools.GetKubeconfigPath(api.dataDir, instanceName) + + if err := utilities.CopySecretBetweenNamespaces(kubeconfigPath, secretName, req.SourceNamespace, req.DestinationNamespace); err != nil { respondError(w, http.StatusInternalServerError, "Failed to copy secret") return } @@ -145,7 +165,19 @@ func (api *API) UtilitiesSecretCopy(w http.ResponseWriter, r *http.Request) { // UtilitiesVersion returns cluster and Talos versions func (api *API) UtilitiesVersion(w http.ResponseWriter, r *http.Request) { - k8sVersion, err := utilities.GetClusterVersion() + vars := mux.Vars(r) + instanceName := vars["name"] + + // Validate instance exists + if err := api.instance.ValidateInstance(instanceName); err != nil { + respondError(w, http.StatusNotFound, fmt.Sprintf("Instance not found: %v", err)) + return + } + + // Get kubeconfig path for this instance + kubeconfigPath := tools.GetKubeconfigPath(api.dataDir, instanceName) + + k8sVersion, err := utilities.GetClusterVersion(kubeconfigPath) if err != nil { respondError(w, http.StatusInternalServerError, "Failed to get cluster version") return diff --git a/internal/cluster/cluster.go b/internal/cluster/cluster.go index b7a9dcf..73016ba 100644 --- a/internal/cluster/cluster.go +++ b/internal/cluster/cluster.go @@ -273,7 +273,8 @@ func (m *Manager) GetStatus(instanceName string) (*ClusterStatus, error) { } // Get node count and types using kubectl - cmd := exec.Command("kubectl", "--kubeconfig", kubeconfigPath, "get", "nodes", "-o", "json") + cmd := exec.Command("kubectl", "get", "nodes", "-o", "json") + tools.WithKubeconfig(cmd, kubeconfigPath) output, err := cmd.Output() if err != nil { status.Status = "unreachable" @@ -356,9 +357,9 @@ func (m *Manager) GetStatus(instanceName string) (*ClusterStatus, error) { } for _, svc := range services { - cmd := exec.Command("kubectl", "--kubeconfig", kubeconfigPath, - "get", "pods", "-n", svc.namespace, "-l", svc.selector, + cmd := exec.Command("kubectl", "get", "pods", "-n", svc.namespace, "-l", svc.selector, "-o", "jsonpath={.items[*].status.phase}") + tools.WithKubeconfig(cmd, kubeconfigPath) output, err := cmd.Output() if err != nil || len(output) == 0 { status.Services[svc.name] = "not_found" diff --git a/internal/services/services.go b/internal/services/services.go index 883eedb..fa0b8e9 100644 --- a/internal/services/services.go +++ b/internal/services/services.go @@ -119,7 +119,8 @@ func (m *Manager) checkServiceStatus(instanceName, serviceName string) string { // Special case: NFS doesn't have a deployment, check for StorageClass instead if serviceName == "nfs" { - cmd := exec.Command("kubectl", "--kubeconfig", kubeconfigPath, "get", "storageclass", "nfs", "-o", "name") + cmd := exec.Command("kubectl", "get", "storageclass", "nfs", "-o", "name") + tools.WithKubeconfig(cmd, kubeconfigPath) if err := cmd.Run(); err == nil { return "deployed" } diff --git a/internal/utilities/utilities.go b/internal/utilities/utilities.go index 2dc0fe5..c1e1639 100644 --- a/internal/utilities/utilities.go +++ b/internal/utilities/utilities.go @@ -84,12 +84,8 @@ func GetClusterHealth(kubeconfigPath string) (*HealthStatus, error) { // checkComponent checks if a component is running func checkComponent(kubeconfigPath, namespace, selector string) error { - args := []string{"get", "pods", "-n", namespace, "-l", selector, "-o", "json"} - if kubeconfigPath != "" { - args = append([]string{"--kubeconfig", kubeconfigPath}, args...) - } - - cmd := exec.Command("kubectl", args...) + cmd := exec.Command("kubectl", "get", "pods", "-n", namespace, "-l", selector, "-o", "json") + tools.WithKubeconfig(cmd, kubeconfigPath) output, err := cmd.Output() if err != nil { return fmt.Errorf("failed to get pods: %w", err) @@ -172,8 +168,9 @@ func GetDashboardTokenFromSecret(kubeconfigPath string) (*DashboardToken, error) } // GetNodeIPs returns IP addresses for all cluster nodes -func GetNodeIPs() ([]*NodeIP, error) { +func GetNodeIPs(kubeconfigPath string) ([]*NodeIP, error) { cmd := exec.Command("kubectl", "get", "nodes", "-o", "json") + tools.WithKubeconfig(cmd, kubeconfigPath) output, err := cmd.Output() if err != nil { return nil, fmt.Errorf("failed to get nodes: %w", err) @@ -217,9 +214,10 @@ func GetNodeIPs() ([]*NodeIP, error) { } // GetControlPlaneIP returns the IP of the first control plane node -func GetControlPlaneIP() (string, error) { +func GetControlPlaneIP(kubeconfigPath string) (string, error) { cmd := exec.Command("kubectl", "get", "nodes", "-l", "node-role.kubernetes.io/control-plane", "-o", "jsonpath={.items[0].status.addresses[?(@.type==\"InternalIP\")].address}") + tools.WithKubeconfig(cmd, kubeconfigPath) output, err := cmd.Output() if err != nil { return "", fmt.Errorf("failed to get control plane IP: %w", err) @@ -234,9 +232,10 @@ func GetControlPlaneIP() (string, error) { } // CopySecretBetweenNamespaces copies a secret from one namespace to another -func CopySecretBetweenNamespaces(secretName, srcNamespace, dstNamespace string) error { +func CopySecretBetweenNamespaces(kubeconfigPath, secretName, srcNamespace, dstNamespace string) error { // Get secret from source namespace cmd := exec.Command("kubectl", "get", "secret", "-n", srcNamespace, secretName, "-o", "json") + tools.WithKubeconfig(cmd, kubeconfigPath) output, err := cmd.Output() if err != nil { return fmt.Errorf("failed to get secret from %s: %w", srcNamespace, err) @@ -264,6 +263,7 @@ func CopySecretBetweenNamespaces(secretName, srcNamespace, dstNamespace string) // Apply to destination namespace cmd = exec.Command("kubectl", "apply", "-f", "-") + tools.WithKubeconfig(cmd, kubeconfigPath) cmd.Stdin = strings.NewReader(string(secretJSON)) if output, err := cmd.CombinedOutput(); err != nil { return fmt.Errorf("failed to apply secret to %s: %w\nOutput: %s", dstNamespace, err, string(output)) @@ -273,8 +273,9 @@ func CopySecretBetweenNamespaces(secretName, srcNamespace, dstNamespace string) } // GetClusterVersion returns the Kubernetes cluster version -func GetClusterVersion() (string, error) { +func GetClusterVersion(kubeconfigPath string) (string, error) { cmd := exec.Command("kubectl", "version", "-o", "json") + tools.WithKubeconfig(cmd, kubeconfigPath) output, err := cmd.Output() if err != nil { return "", fmt.Errorf("failed to get cluster version: %w", err)