diff --git a/doc/ovhcloud_cloud_kube.md b/doc/ovhcloud_cloud_kube.md index 30112490..82ff3ad1 100644 --- a/doc/ovhcloud_cloud_kube.md +++ b/doc/ovhcloud_cloud_kube.md @@ -36,6 +36,7 @@ Manage Kubernetes clusters in the given cloud project * [ovhcloud cloud kube edit](ovhcloud_cloud_kube_edit.md) - Edit the given Kubernetes cluster * [ovhcloud cloud kube get](ovhcloud_cloud_kube_get.md) - Get the given Kubernetes cluster * [ovhcloud cloud kube ip-restrictions](ovhcloud_cloud_kube_ip-restrictions.md) - Manage IP restrictions for Kubernetes clusters +* [ovhcloud cloud kube k9s](ovhcloud_cloud_kube_k9s.md) - Generate kubeconfig and launch k9s for the given Kubernetes cluster * [ovhcloud cloud kube kubeconfig](ovhcloud_cloud_kube_kubeconfig.md) - Manage the kubeconfig for the given Kubernetes cluster * [ovhcloud cloud kube list](ovhcloud_cloud_kube_list.md) - List your Kubernetes clusters * [ovhcloud cloud kube node](ovhcloud_cloud_kube_node.md) - Manage Kubernetes nodes @@ -45,5 +46,6 @@ Manage Kubernetes clusters in the given cloud project * [ovhcloud cloud kube reset](ovhcloud_cloud_kube_reset.md) - Reset the given Kubernetes cluster * [ovhcloud cloud kube restart](ovhcloud_cloud_kube_restart.md) - Restart control plane apiserver to invalidate cache without downtime * [ovhcloud cloud kube set-load-balancers-subnet](ovhcloud_cloud_kube_set-load-balancers-subnet.md) - Update the load balancers subnet ID for the given Kubernetes cluster +* [ovhcloud cloud kube shell](ovhcloud_cloud_kube_shell.md) - Generate kubeconfig and open an interactive shell with kubectl access * [ovhcloud cloud kube update](ovhcloud_cloud_kube_update.md) - Update the given Kubernetes cluster diff --git a/doc/ovhcloud_cloud_kube_ip-restrictions.md b/doc/ovhcloud_cloud_kube_ip-restrictions.md index 858cdb78..4a282f56 100644 --- a/doc/ovhcloud_cloud_kube_ip-restrictions.md +++ b/doc/ovhcloud_cloud_kube_ip-restrictions.md @@ -30,6 +30,7 @@ Manage IP restrictions for Kubernetes clusters ### SEE ALSO * [ovhcloud cloud kube](ovhcloud_cloud_kube.md) - Manage Kubernetes clusters in the given cloud project +* [ovhcloud cloud kube ip-restrictions add-my-ip](ovhcloud_cloud_kube_ip-restrictions_add-my-ip.md) - Add your public IP to the cluster's IP restrictions * [ovhcloud cloud kube ip-restrictions edit](ovhcloud_cloud_kube_ip-restrictions_edit.md) - Edit IP restrictions for the given Kubernetes cluster * [ovhcloud cloud kube ip-restrictions list](ovhcloud_cloud_kube_ip-restrictions_list.md) - List IP restrictions for the given Kubernetes cluster diff --git a/doc/ovhcloud_cloud_kube_ip-restrictions_add-my-ip.md b/doc/ovhcloud_cloud_kube_ip-restrictions_add-my-ip.md new file mode 100644 index 00000000..d791e4b2 --- /dev/null +++ b/doc/ovhcloud_cloud_kube_ip-restrictions_add-my-ip.md @@ -0,0 +1,41 @@ +## ovhcloud cloud kube ip-restrictions add-my-ip + +Add your public IP to the cluster's IP restrictions + +### Synopsis + +Automatically detect your public IP address and add it to the cluster's IP restrictions. Requires that IP restrictions are already enabled on the cluster. + +``` +ovhcloud cloud kube ip-restrictions add-my-ip [flags] +``` + +### Options + +``` + -h, --help help for add-my-ip +``` + +### Options inherited from parent commands + +``` + --cloud-project string Cloud project ID + -d, --debug Activate debug mode (will log all HTTP requests details) + -f, --format string Output value according to given format (expression using https://github.com/PaesslerAG/gval syntax) + Examples: + --format 'id' (to extract a single field) + --format 'nested.field.subfield' (to extract a nested field) + --format '[id, 'name']' (to extract multiple fields as an array) + --format '{"newKey": oldKey, "otherKey": nested.field}' (to extract and rename fields in an object) + --format 'name+","+type' (to extract and concatenate fields in a string) + --format '(nbFieldA + nbFieldB) * 10' (to compute values from numeric fields) + -e, --ignore-errors Ignore errors in API calls when it is not fatal to the execution + -i, --interactive Interactive output + -j, --json Output in JSON + -y, --yaml Output in YAML +``` + +### SEE ALSO + +* [ovhcloud cloud kube ip-restrictions](ovhcloud_cloud_kube_ip-restrictions.md) - Manage IP restrictions for Kubernetes clusters + diff --git a/doc/ovhcloud_cloud_kube_k9s.md b/doc/ovhcloud_cloud_kube_k9s.md new file mode 100644 index 00000000..320f8e44 --- /dev/null +++ b/doc/ovhcloud_cloud_kube_k9s.md @@ -0,0 +1,41 @@ +## ovhcloud cloud kube k9s + +Generate kubeconfig and launch k9s for the given Kubernetes cluster + +### Synopsis + +Generate kubeconfig, save it to ~/.kube/ovhcloud-.yaml, and launch k9s with the configuration + +``` +ovhcloud cloud kube k9s [flags] +``` + +### Options + +``` + -h, --help help for k9s +``` + +### Options inherited from parent commands + +``` + --cloud-project string Cloud project ID + -d, --debug Activate debug mode (will log all HTTP requests details) + -f, --format string Output value according to given format (expression using https://github.com/PaesslerAG/gval syntax) + Examples: + --format 'id' (to extract a single field) + --format 'nested.field.subfield' (to extract a nested field) + --format '[id, 'name']' (to extract multiple fields as an array) + --format '{"newKey": oldKey, "otherKey": nested.field}' (to extract and rename fields in an object) + --format 'name+","+type' (to extract and concatenate fields in a string) + --format '(nbFieldA + nbFieldB) * 10' (to compute values from numeric fields) + -e, --ignore-errors Ignore errors in API calls when it is not fatal to the execution + -i, --interactive Interactive output + -j, --json Output in JSON + -y, --yaml Output in YAML +``` + +### SEE ALSO + +* [ovhcloud cloud kube](ovhcloud_cloud_kube.md) - Manage Kubernetes clusters in the given cloud project + diff --git a/doc/ovhcloud_cloud_kube_shell.md b/doc/ovhcloud_cloud_kube_shell.md new file mode 100644 index 00000000..be793ab5 --- /dev/null +++ b/doc/ovhcloud_cloud_kube_shell.md @@ -0,0 +1,41 @@ +## ovhcloud cloud kube shell + +Generate kubeconfig and open an interactive shell with kubectl access + +### Synopsis + +Generate kubeconfig, save it to ~/.kube/ovhcloud-.yaml, and open a shell with KUBECONFIG set + +``` +ovhcloud cloud kube shell [flags] +``` + +### Options + +``` + -h, --help help for shell +``` + +### Options inherited from parent commands + +``` + --cloud-project string Cloud project ID + -d, --debug Activate debug mode (will log all HTTP requests details) + -f, --format string Output value according to given format (expression using https://github.com/PaesslerAG/gval syntax) + Examples: + --format 'id' (to extract a single field) + --format 'nested.field.subfield' (to extract a nested field) + --format '[id, 'name']' (to extract multiple fields as an array) + --format '{"newKey": oldKey, "otherKey": nested.field}' (to extract and rename fields in an object) + --format 'name+","+type' (to extract and concatenate fields in a string) + --format '(nbFieldA + nbFieldB) * 10' (to compute values from numeric fields) + -e, --ignore-errors Ignore errors in API calls when it is not fatal to the execution + -i, --interactive Interactive output + -j, --json Output in JSON + -y, --yaml Output in YAML +``` + +### SEE ALSO + +* [ovhcloud cloud kube](ovhcloud_cloud_kube.md) - Manage Kubernetes clusters in the given cloud project + diff --git a/internal/cmd/cloud_kube.go b/internal/cmd/cloud_kube.go index 64ed4346..c0d78d87 100644 --- a/internal/cmd/cloud_kube.go +++ b/internal/cmd/cloud_kube.go @@ -113,6 +113,14 @@ func initKubeCommand(cloudCmd *cobra.Command) { ipRestrictionsEditCmd.MarkFlagsMutuallyExclusive("ips", "editor") ipRestrictionsCmd.AddCommand(ipRestrictionsEditCmd) + ipRestrictionsCmd.AddCommand(&cobra.Command{ + Use: "add-my-ip ", + Short: "Add your public IP to the cluster's IP restrictions", + Long: "Automatically detect your public IP address and add it to the cluster's IP restrictions. Requires that IP restrictions are already enabled on the cluster.", + Run: cloud.AddMyIPToKubeRestrictions, + Args: cobra.ExactArgs(1), + }) + kubeConfigCmd := &cobra.Command{ Use: "kubeconfig", Short: "Manage the kubeconfig for the given Kubernetes cluster", @@ -133,6 +141,24 @@ func initKubeCommand(cloudCmd *cobra.Command) { Args: cobra.ExactArgs(1), }) + // k9s command - generate kubeconfig and launch k9s + kubeCmd.AddCommand(&cobra.Command{ + Use: "k9s ", + Short: "Generate kubeconfig and launch k9s for the given Kubernetes cluster", + Long: "Generate kubeconfig, save it to ~/.kube/ovhcloud-.yaml, and launch k9s with the configuration", + Run: cloud.LaunchK9s, + Args: cobra.ExactArgs(1), + }) + + // shell command - generate kubeconfig and open interactive shell + kubeCmd.AddCommand(&cobra.Command{ + Use: "shell ", + Short: "Generate kubeconfig and open an interactive shell with kubectl access", + Long: "Generate kubeconfig, save it to ~/.kube/ovhcloud-.yaml, and open a shell with KUBECONFIG set", + Run: cloud.LaunchKubeShell, + Args: cobra.ExactArgs(1), + }) + nodeCmd := &cobra.Command{ Use: "node", Short: "Manage Kubernetes nodes", diff --git a/internal/services/browser/api.go b/internal/services/browser/api.go index 6df283a0..fd2a260d 100644 --- a/internal/services/browser/api.go +++ b/internal/services/browser/api.go @@ -2301,3 +2301,669 @@ func (m Model) handleNetworkCreated(msg networkCreatedMsg) (tea.Model, tea.Cmd) return clearNotificationMsg{} }) } + +// ============================================ +// Kubernetes Actions (kubeconfig, kubectl, k9s) +// ============================================ + +// executeKubernetesAction executes an action on the current Kubernetes cluster +func (m Model) executeKubernetesAction(actionIndex int) tea.Cmd { + return func() tea.Msg { + if m.detailData == nil { + return kubeActionMsg{err: fmt.Errorf("no cluster selected")} + } + + clusterId := getString(m.detailData, "id") + clusterName := getString(m.detailData, "name") + if clusterId == "" { + return kubeActionMsg{err: fmt.Errorf("cluster ID not found")} + } + + actions := []string{"kubeconfig", "kubectl", "k9s", "reset_kubeconfig", "add_my_ip", "ip_config"} + if actionIndex < 0 || actionIndex >= len(actions) { + return kubeActionMsg{err: fmt.Errorf("invalid action index")} + } + + action := actions[actionIndex] + + // Special handling for IP config - trigger IP restrictions loading + if action == "ip_config" { + // Fetch IP restrictions directly and return them + endpoint := fmt.Sprintf("/v1/cloud/project/%s/kube/%s/ipRestrictions", m.cloudProject, clusterId) + rawIPs, err := httpLib.FetchArray(endpoint, "") + if err != nil { + return ipRestrictionsLoadedMsg{err: fmt.Errorf("failed to fetch IP restrictions: %w", err)} + } + + // Convert to string slice + ips := make([]string, 0, len(rawIPs)) + for _, ip := range rawIPs { + if ipStr, ok := ip.(string); ok { + ips = append(ips, ipStr) + } + } + + return ipRestrictionsLoadedMsg{ips: ips} + } + + switch action { + case "kubeconfig": + // Generate and save kubeconfig only + configPath, err := generateKubeconfig(m.cloudProject, clusterId, clusterName) + if err != nil { + return kubeActionMsg{action: action, clusterName: clusterName, err: err} + } + return kubeActionMsg{action: action, clusterName: clusterName, configPath: configPath} + + case "kubectl": + // Check if IP needs to be added to restrictions + needsAdd, ipCIDR := checkIPInRestrictions(m.cloudProject, clusterId) + if needsAdd { + // Return message to ask for confirmation + return ipRestrictionCheckMsg{ + needsAdd: true, + ipCIDR: ipCIDR, + actionIndex: actionIndex, + clusterName: clusterName, + } + } + + // Generate kubeconfig and launch kubectl + configPath, err := generateKubeconfig(m.cloudProject, clusterId, clusterName) + if err != nil { + return kubeActionMsg{action: action, clusterName: clusterName, err: err} + } + // Return a message that will trigger kubectl launch + return kubeConfigMsg{configPath: configPath, clusterName: clusterName} + + case "k9s": + // Check if IP needs to be added to restrictions + needsAdd, ipCIDR := checkIPInRestrictions(m.cloudProject, clusterId) + if needsAdd { + // Return message to ask for confirmation + return ipRestrictionCheckMsg{ + needsAdd: true, + ipCIDR: ipCIDR, + actionIndex: actionIndex, + clusterName: clusterName, + } + } + + // Generate kubeconfig and launch k9s + configPath, err := generateKubeconfig(m.cloudProject, clusterId, clusterName) + if err != nil { + return kubeActionMsg{action: action, clusterName: clusterName, err: err} + } + // Return a message that will trigger k9s launch with special marker + return kubeConfigMsg{configPath: configPath, clusterName: clusterName + "\x00k9s"} + + case "reset_kubeconfig": + // Reset/regenerate kubeconfig + endpoint := fmt.Sprintf("/v1/cloud/project/%s/kube/%s/kubeconfig/reset", m.cloudProject, clusterId) + err := httpLib.Client.Post(endpoint, nil, nil) + if err != nil { + return kubeActionMsg{action: action, clusterName: clusterName, err: err} + } + return kubeActionMsg{action: action, clusterName: clusterName} + + case "add_my_ip": + // Add client's public IP to cluster's IP restrictions + ipCIDR, err := addMyIPToRestrictions(m.cloudProject, clusterId) + if err != nil { + return kubeActionMsg{action: action, clusterName: clusterName, err: err} + } + return kubeActionMsg{action: action, clusterName: clusterName, configPath: ipCIDR} + } + + return kubeActionMsg{action: action, clusterName: clusterName, err: fmt.Errorf("unknown action")} + } +} + +// executeKubeActionWithIPAdd adds the IP to restrictions and then executes the kubectl/k9s action +func (m Model) executeKubeActionWithIPAdd(actionIndex int, ipCIDR string) tea.Cmd { + return func() tea.Msg { + if m.detailData == nil { + return kubeActionMsg{err: fmt.Errorf("no cluster selected")} + } + + clusterId := getString(m.detailData, "id") + clusterName := getString(m.detailData, "name") + if clusterId == "" { + return kubeActionMsg{err: fmt.Errorf("cluster ID not found")} + } + + // Add IP to restrictions + if err := addIPToRestrictions(m.cloudProject, clusterId, ipCIDR); err != nil { + return kubeActionMsg{action: "add_ip", clusterName: clusterName, err: err} + } + + // Now proceed with the action + if actionIndex == 1 { // kubectl + configPath, err := generateKubeconfig(m.cloudProject, clusterId, clusterName) + if err != nil { + return kubeActionMsg{action: "kubectl", clusterName: clusterName, err: err} + } + return kubeConfigMsg{configPath: configPath, clusterName: clusterName} + } else if actionIndex == 2 { // k9s + configPath, err := generateKubeconfig(m.cloudProject, clusterId, clusterName) + if err != nil { + return kubeActionMsg{action: "k9s", clusterName: clusterName, err: err} + } + return kubeConfigMsg{configPath: configPath, clusterName: clusterName + "\x00k9s"} + } + + return kubeActionMsg{err: fmt.Errorf("invalid action")} + } +} + +// executeKubernetesActionSkipIPCheck executes kubectl/k9s without checking/adding IP +func (m Model) executeKubernetesActionSkipIPCheck(actionIndex int) tea.Cmd { + return func() tea.Msg { + if m.detailData == nil { + return kubeActionMsg{err: fmt.Errorf("no cluster selected")} + } + + clusterId := getString(m.detailData, "id") + clusterName := getString(m.detailData, "name") + if clusterId == "" { + return kubeActionMsg{err: fmt.Errorf("cluster ID not found")} + } + + // Proceed with the action without adding IP + if actionIndex == 1 { // kubectl + configPath, err := generateKubeconfig(m.cloudProject, clusterId, clusterName) + if err != nil { + return kubeActionMsg{action: "kubectl", clusterName: clusterName, err: err} + } + return kubeConfigMsg{configPath: configPath, clusterName: clusterName} + } else if actionIndex == 2 { // k9s + configPath, err := generateKubeconfig(m.cloudProject, clusterId, clusterName) + if err != nil { + return kubeActionMsg{action: "k9s", clusterName: clusterName, err: err} + } + return kubeConfigMsg{configPath: configPath, clusterName: clusterName + "\x00k9s"} + } + + return kubeActionMsg{err: fmt.Errorf("invalid action")} + } +} + +// generateKubeconfig fetches kubeconfig from API and saves it to a file +func generateKubeconfig(projectID, clusterID, clusterName string) (string, error) { + // Fetch kubeconfig from API + endpoint := fmt.Sprintf("/v1/cloud/project/%s/kube/%s/kubeconfig", projectID, clusterID) + var kubeConfig map[string]interface{} + if err := httpLib.Client.Post(endpoint, nil, &kubeConfig); err != nil { + return "", fmt.Errorf("failed to generate kubeconfig: %w", err) + } + + content, ok := kubeConfig["content"].(string) + if !ok || content == "" { + return "", fmt.Errorf("kubeconfig content not found in response") + } + + // Sanitize cluster name for filename + safeName := sanitizeFilename(clusterName) + if safeName == "" { + safeName = clusterID[:8] // Use first 8 chars of ID as fallback + } + + // Ensure ~/.kube directory exists + homeDir, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("failed to get home directory: %w", err) + } + + kubeDir := fmt.Sprintf("%s/.kube", homeDir) + if err := os.MkdirAll(kubeDir, 0755); err != nil { + return "", fmt.Errorf("failed to create .kube directory: %w", err) + } + + // Save kubeconfig to file + configPath := fmt.Sprintf("%s/ovhcloud-%s.yaml", kubeDir, safeName) + if err := os.WriteFile(configPath, []byte(content), 0600); err != nil { + return "", fmt.Errorf("failed to write kubeconfig: %w", err) + } + + return configPath, nil +} + +// sanitizeFilename removes or replaces characters not suitable for filenames +func sanitizeFilename(name string) string { + // Replace spaces and special characters with dashes + result := strings.Map(func(r rune) rune { + if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '-' || r == '_' { + return r + } + if r == ' ' { + return '-' + } + return -1 // Remove other characters + }, name) + + // Convert to lowercase and trim + result = strings.ToLower(strings.Trim(result, "-_")) + + // Limit length + if len(result) > 50 { + result = result[:50] + } + + return result +} + +// addMyIPToRestrictions adds the client's public IP to the cluster's IP restrictions +func addMyIPToRestrictions(projectID, clusterID string) (string, error) { + // Fetch current IP restrictions + endpoint := fmt.Sprintf("/v1/cloud/project/%s/kube/%s/ipRestrictions", projectID, clusterID) + currentIPs, err := httpLib.FetchArray(endpoint, "") + if err != nil { + return "", fmt.Errorf("failed to fetch IP restrictions: %w", err) + } + + // Check if restrictions are enabled (non-empty list) + if len(currentIPs) == 0 { + return "", fmt.Errorf("IP restrictions are not enabled for this cluster") + } + + // Get public IP + publicIP, err := getPublicIP() + if err != nil { + return "", fmt.Errorf("failed to detect your public IP: %w", err) + } + + // Add /32 suffix for single IP + ipCIDR := publicIP + "/32" + + // Check if IP is already in the list + ipList := make([]string, 0, len(currentIPs)+1) + for _, ip := range currentIPs { + ipStr, ok := ip.(string) + if !ok { + continue + } + if ipStr == ipCIDR || ipStr == publicIP { + return "", fmt.Errorf("your IP (%s) is already in the restrictions list", ipCIDR) + } + ipList = append(ipList, ipStr) + } + + // Append the new IP + ipList = append(ipList, ipCIDR) + + // Update the restrictions + body := map[string]any{ + "ips": ipList, + } + + if err := httpLib.Client.Put(endpoint, body, nil); err != nil { + return "", fmt.Errorf("failed to update IP restrictions: %w", err) + } + + return ipCIDR, nil +} + +// getPublicIP detects the client's public IPv4 address using OVH's geolocation API +// Note: Only IPv4 is supported by OVH Managed Kubernetes API server +func getPublicIP() (string, error) { + var geolocation struct { + Continent string `json:"continent"` + CountryCode string `json:"countryCode"` + IP string `json:"ip"` + } + + if err := httpLib.Client.Post("/v1/me/geolocation", nil, &geolocation); err != nil { + return "", fmt.Errorf("failed to detect public IP: %w", err) + } + + if geolocation.IP == "" { + return "", fmt.Errorf("failed to detect public IP: empty response") + } + + // Ensure we got an IPv4 address (contains dots, no colons) + if !isIPv4(geolocation.IP) { + return "", fmt.Errorf("failed to detect public IPv4: got IPv6 address %s", geolocation.IP) + } + + return geolocation.IP, nil +} + +// isIPv4 checks if the given IP string is an IPv4 address +func isIPv4(ip string) bool { + return strings.Contains(ip, ".") && !strings.Contains(ip, ":") +} + +// checkIPInRestrictions checks if the user's IP is in the cluster's IP restrictions. +// Returns (needsAdd, ipCIDR) - if needsAdd is true, the IP should be added. +func checkIPInRestrictions(projectID, clusterID string) (bool, string) { + // Fetch current IP restrictions + endpoint := fmt.Sprintf("/v1/cloud/project/%s/kube/%s/ipRestrictions", projectID, clusterID) + currentIPs, err := httpLib.FetchArray(endpoint, "") + if err != nil { + return false, "" // Can't check, assume OK + } + + // If no restrictions are enabled, nothing to check + if len(currentIPs) == 0 { + return false, "" + } + + // Get public IP + publicIP, err := getPublicIP() + if err != nil { + return false, "" // Can't detect IP, assume OK + } + + ipCIDR := publicIP + "/32" + + // Check if IP is already in the list + for _, ip := range currentIPs { + ipStr, ok := ip.(string) + if !ok { + continue + } + if ipStr == ipCIDR || ipStr == publicIP { + return false, ipCIDR // IP is already in the list + } + } + + // IP needs to be added + return true, ipCIDR +} + +// addIPToRestrictions adds the given IP to the cluster's IP restrictions. +// Returns error if it fails. +func addIPToRestrictions(projectID, clusterID, ipCIDR string) error { + endpoint := fmt.Sprintf("/v1/cloud/project/%s/kube/%s/ipRestrictions", projectID, clusterID) + currentIPs, err := httpLib.FetchArray(endpoint, "") + if err != nil { + return fmt.Errorf("failed to fetch IP restrictions: %w", err) + } + + // Build the new IP list + ipList := make([]string, 0, len(currentIPs)+1) + for _, ip := range currentIPs { + if ipStr, ok := ip.(string); ok { + ipList = append(ipList, ipStr) + } + } + ipList = append(ipList, ipCIDR) + + // Update the restrictions + body := map[string]any{ + "ips": ipList, + } + + if err := httpLib.Client.Put(endpoint, body, nil); err != nil { + return fmt.Errorf("failed to update IP restrictions: %w", err) + } + + return nil +} + +// ============================================ +// IP Restrictions Management Functions +// ============================================ + +// fetchIPRestrictions fetches the current IP restrictions for a Kubernetes cluster +func (m Model) fetchIPRestrictions() tea.Cmd { + return func() tea.Msg { + if m.detailData == nil { + return ipRestrictionsLoadedMsg{err: fmt.Errorf("no cluster selected")} + } + + clusterID := getString(m.detailData, "id") + if clusterID == "" { + return ipRestrictionsLoadedMsg{err: fmt.Errorf("cluster ID not found")} + } + + endpoint := fmt.Sprintf("/v1/cloud/project/%s/kube/%s/ipRestrictions", m.cloudProject, clusterID) + rawIPs, err := httpLib.FetchArray(endpoint, "") + if err != nil { + return ipRestrictionsLoadedMsg{err: fmt.Errorf("failed to fetch IP restrictions: %w", err)} + } + + // Convert to string slice + ips := make([]string, 0, len(rawIPs)) + for _, ip := range rawIPs { + if ipStr, ok := ip.(string); ok { + ips = append(ips, ipStr) + } + } + + return ipRestrictionsLoadedMsg{ips: ips} + } +} + +// updateIPRestrictions updates the IP restrictions list +func (m Model) updateIPRestrictions(newIPs []string) tea.Cmd { + return func() tea.Msg { + if m.detailData == nil { + return ipRestrictionsUpdatedMsg{err: fmt.Errorf("no cluster selected")} + } + + clusterID := getString(m.detailData, "id") + if clusterID == "" { + return ipRestrictionsUpdatedMsg{err: fmt.Errorf("cluster ID not found")} + } + + endpoint := fmt.Sprintf("/v1/cloud/project/%s/kube/%s/ipRestrictions", m.cloudProject, clusterID) + body := map[string]any{ + "ips": newIPs, + } + + if err := httpLib.Client.Put(endpoint, body, nil); err != nil { + return ipRestrictionsUpdatedMsg{err: fmt.Errorf("failed to update IP restrictions: %w", err)} + } + + return ipRestrictionsUpdatedMsg{action: "update"} + } +} + +// addIPRestriction adds a new IP to the restrictions list +func (m Model) addIPRestriction(ip string) tea.Cmd { + return func() tea.Msg { + if m.detailData == nil { + return ipRestrictionsUpdatedMsg{err: fmt.Errorf("no cluster selected")} + } + + clusterID := getString(m.detailData, "id") + if clusterID == "" { + return ipRestrictionsUpdatedMsg{err: fmt.Errorf("cluster ID not found")} + } + + // Validate IP format (basic validation) + if !isValidIPCIDR(ip) { + return ipRestrictionsUpdatedMsg{err: fmt.Errorf("invalid IP/CIDR format: %s", ip)} + } + + // Check if IP already exists + for _, existing := range m.ipRestrictions { + if existing == ip { + return ipRestrictionsUpdatedMsg{err: fmt.Errorf("IP %s already exists in restrictions", ip)} + } + } + + // Add to the list + newIPs := make([]string, len(m.ipRestrictions)+1) + copy(newIPs, m.ipRestrictions) + newIPs[len(m.ipRestrictions)] = ip + + endpoint := fmt.Sprintf("/v1/cloud/project/%s/kube/%s/ipRestrictions", m.cloudProject, clusterID) + body := map[string]any{ + "ips": newIPs, + } + + if err := httpLib.Client.Put(endpoint, body, nil); err != nil { + return ipRestrictionsUpdatedMsg{err: fmt.Errorf("failed to add IP restriction: %w", err)} + } + + return ipRestrictionsUpdatedMsg{action: "add", ip: ip} + } +} + +// editIPRestriction replaces an existing IP with a new one +func (m Model) editIPRestriction(oldIP, newIP string) tea.Cmd { + return func() tea.Msg { + if m.detailData == nil { + return ipRestrictionsUpdatedMsg{err: fmt.Errorf("no cluster selected")} + } + + clusterID := getString(m.detailData, "id") + if clusterID == "" { + return ipRestrictionsUpdatedMsg{err: fmt.Errorf("cluster ID not found")} + } + + // Validate new IP format + if !isValidIPCIDR(newIP) { + return ipRestrictionsUpdatedMsg{err: fmt.Errorf("invalid IP/CIDR format: %s", newIP)} + } + + // Build new list with the replacement + newIPs := make([]string, 0, len(m.ipRestrictions)) + found := false + for _, ip := range m.ipRestrictions { + if ip == oldIP { + newIPs = append(newIPs, newIP) + found = true + } else { + newIPs = append(newIPs, ip) + } + } + + if !found { + return ipRestrictionsUpdatedMsg{err: fmt.Errorf("IP %s not found in restrictions", oldIP)} + } + + endpoint := fmt.Sprintf("/v1/cloud/project/%s/kube/%s/ipRestrictions", m.cloudProject, clusterID) + body := map[string]any{ + "ips": newIPs, + } + + if err := httpLib.Client.Put(endpoint, body, nil); err != nil { + return ipRestrictionsUpdatedMsg{err: fmt.Errorf("failed to edit IP restriction: %w", err)} + } + + return ipRestrictionsUpdatedMsg{action: "edit", ip: newIP} + } +} + +// deleteIPRestriction removes an IP from the restrictions list +func (m Model) deleteIPRestriction(ip string) tea.Cmd { + return func() tea.Msg { + if m.detailData == nil { + return ipRestrictionsUpdatedMsg{err: fmt.Errorf("no cluster selected")} + } + + clusterID := getString(m.detailData, "id") + if clusterID == "" { + return ipRestrictionsUpdatedMsg{err: fmt.Errorf("cluster ID not found")} + } + + // Build new list without the deleted IP + newIPs := make([]string, 0, len(m.ipRestrictions)-1) + found := false + for _, existingIP := range m.ipRestrictions { + if existingIP == ip { + found = true + } else { + newIPs = append(newIPs, existingIP) + } + } + + if !found { + return ipRestrictionsUpdatedMsg{err: fmt.Errorf("IP %s not found in restrictions", ip)} + } + + endpoint := fmt.Sprintf("/v1/cloud/project/%s/kube/%s/ipRestrictions", m.cloudProject, clusterID) + body := map[string]any{ + "ips": newIPs, + } + + if err := httpLib.Client.Put(endpoint, body, nil); err != nil { + return ipRestrictionsUpdatedMsg{err: fmt.Errorf("failed to delete IP restriction: %w", err)} + } + + return ipRestrictionsUpdatedMsg{action: "delete", ip: ip} + } +} + +// addMyIPRestriction adds the user's public IP to restrictions +func (m Model) addMyIPRestriction() tea.Cmd { + return func() tea.Msg { + if m.detailData == nil { + return ipRestrictionsUpdatedMsg{err: fmt.Errorf("no cluster selected")} + } + + clusterID := getString(m.detailData, "id") + if clusterID == "" { + return ipRestrictionsUpdatedMsg{err: fmt.Errorf("cluster ID not found")} + } + + // Get public IP + publicIP, err := getPublicIP() + if err != nil { + return ipRestrictionsUpdatedMsg{err: fmt.Errorf("failed to detect your public IP: %w", err)} + } + + ipCIDR := publicIP + "/32" + + // Check if IP already exists + for _, existing := range m.ipRestrictions { + if existing == ipCIDR || existing == publicIP { + return ipRestrictionsUpdatedMsg{err: fmt.Errorf("your IP (%s) is already in the restrictions list", ipCIDR)} + } + } + + // Add to the list + newIPs := make([]string, len(m.ipRestrictions)+1) + copy(newIPs, m.ipRestrictions) + newIPs[len(m.ipRestrictions)] = ipCIDR + + endpoint := fmt.Sprintf("/v1/cloud/project/%s/kube/%s/ipRestrictions", m.cloudProject, clusterID) + body := map[string]any{ + "ips": newIPs, + } + + if err := httpLib.Client.Put(endpoint, body, nil); err != nil { + return ipRestrictionsUpdatedMsg{err: fmt.Errorf("failed to add IP restriction: %w", err)} + } + + return ipRestrictionsUpdatedMsg{action: "add", ip: ipCIDR} + } +} + +// isValidIPCIDR performs basic validation of IP/CIDR format +func isValidIPCIDR(ip string) bool { + if ip == "" { + return false + } + + // Check if it contains a CIDR suffix + if strings.Contains(ip, "/") { + parts := strings.Split(ip, "/") + if len(parts) != 2 { + return false + } + // Basic check for CIDR suffix (0-128 for IPv6, 0-32 for IPv4) + cidr := parts[1] + if len(cidr) == 0 || len(cidr) > 3 { + return false + } + for _, c := range cidr { + if c < '0' || c > '9' { + return false + } + } + } + + // Check if IP part contains valid characters (IPv4 or IPv6) + ipPart := strings.Split(ip, "/")[0] + for _, c := range ipPart { + if !((c >= '0' && c <= '9') || c == '.' || c == ':' || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F')) { + return false + } + } + + return true +} diff --git a/internal/services/browser/manager.go b/internal/services/browser/manager.go index b3d3b1b3..bdfa666b 100644 --- a/internal/services/browser/manager.go +++ b/internal/services/browser/manager.go @@ -34,10 +34,11 @@ const ( DetailView // Detail view for a single item LoadingView ErrorView - EmptyView // Empty list with creation prompt - WizardView // Multi-step wizard for resource creation - DeleteConfirmView // Confirmation dialog for deletion - DebugView // Debug panel showing API requests + EmptyView // Empty list with creation prompt + WizardView // Multi-step wizard for resource creation + DeleteConfirmView // Confirmation dialog for deletion + DebugView // Debug panel showing API requests + IPRestrictionsView // IP restrictions management for Kubernetes ) // ASCII OVHcloud logo for loading screen @@ -157,6 +158,10 @@ type Model struct { wizard WizardData // Wizard state for resource creation selectedAction int // Selected action index in detail view (0-5) actionConfirm bool // Whether we're in confirmation mode for an action + // IP restriction confirmation for kubectl/k9s + ipAddConfirm bool // Whether we're asking to add IP to restrictions + pendingKubeAction int // The kubectl/k9s action index waiting for IP confirmation + pendingIPCIDR string // The detected IP that needs to be added // Filter mode filterMode bool // Whether filter input mode is active filterInput string // Current filter input text @@ -168,6 +173,12 @@ type Model struct { // Instance data cache imageMap map[string]string // imageId -> imageName (for instances) floatingIPMap map[string]string // instanceId -> floatingIP address + // IP Restrictions management + ipRestrictions []string // Current IP restrictions list + ipRestrictionsIdx int // Selected index in IP list + ipRestrictionsMode string // "list", "add", "edit" + ipRestrictionsInput string // Input buffer for add/edit + ipRestrictionsEditIP string // Original IP being edited (for replacement) } // Navigation items for the top bar @@ -393,6 +404,49 @@ type sshConnectionMsg struct { user string } +// kubeConfigMsg is returned when kubeconfig generation completes +type kubeConfigMsg struct { + configPath string + clusterName string + err error +} + +// kubeToolLaunchMsg is returned when a kube tool (kubectl/k9s) finishes +type kubeToolLaunchMsg struct { + tool string + err error +} + +// kubeActionMsg is returned when a Kubernetes action completes (non-tool actions) +type kubeActionMsg struct { + action string + clusterName string + configPath string + err error +} + +// ipRestrictionCheckMsg is returned when checking if user's IP is in restrictions +type ipRestrictionCheckMsg struct { + needsAdd bool // Whether IP needs to be added + ipCIDR string // The detected IP (with /32) + actionIndex int // The pending action (1=kubectl, 2=k9s) + clusterName string + err error +} + +// ipRestrictionsLoadedMsg is returned when IP restrictions are loaded +type ipRestrictionsLoadedMsg struct { + ips []string + err error +} + +// ipRestrictionsUpdatedMsg is returned when IP restrictions are updated +type ipRestrictionsUpdatedMsg struct { + action string // "add", "edit", "delete" + ip string // The IP that was affected + err error +} + // Navigation items for products (shown after project is selected) func getNavItems() []NavItem { return []NavItem{ @@ -551,6 +605,24 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case sshConnectionMsg: return m.handleSSHConnection(msg) + case kubeConfigMsg: + return m.handleKubeConfig(msg) + + case kubeToolLaunchMsg: + return m.handleKubeToolLaunch(msg) + + case kubeActionMsg: + return m.handleKubeAction(msg) + + case ipRestrictionCheckMsg: + return m.handleIPRestrictionCheck(msg) + + case ipRestrictionsLoadedMsg: + return m.handleIPRestrictionsLoaded(msg) + + case ipRestrictionsUpdatedMsg: + return m.handleIPRestrictionsUpdated(msg) + case cleanupCompletedMsg: return m.handleCleanupCompleted(msg) } @@ -623,6 +695,221 @@ func (m Model) handleSSHConnection(msg sshConnectionMsg) (tea.Model, tea.Cmd) { }) } +// handleKubeConfig handles kubeconfig generation result and launches kubectl/k9s +func (m Model) handleKubeConfig(msg kubeConfigMsg) (tea.Model, tea.Cmd) { + if msg.err != nil { + m.notification = fmt.Sprintf("❌ Kubeconfig error: %s", msg.err) + m.notificationExpiry = time.Now().Add(5 * time.Second) + return m, tea.Tick(5*time.Second, func(t time.Time) tea.Msg { + return clearNotificationMsg{} + }) + } + + // Check if this is for k9s launch (marked with \x00k9s suffix) + tool := "kubectl" + if strings.HasSuffix(msg.clusterName, "\x00k9s") { + tool = "k9s" + } + + // Check if the tool is installed + toolPath, err := exec.LookPath(tool) + if err != nil { + var installHint string + if tool == "kubectl" { + installHint = "Install with: https://kubernetes.io/docs/tasks/tools/" + } else { + installHint = "Install with: brew install k9s (or https://k9scli.io/)" + } + m.notification = fmt.Sprintf("❌ %s not found. %s", tool, installHint) + m.notificationExpiry = time.Now().Add(8 * time.Second) + return m, tea.Tick(8*time.Second, func(t time.Time) tea.Msg { + return clearNotificationMsg{} + }) + } + + // Log the tool launch to debug panel + httpLib.BrowserDebugLogger.AddEntry(httpLib.DebugLogEntry{ + Timestamp: time.Now(), + Method: "EXEC", + URL: fmt.Sprintf("%s (KUBECONFIG=%s)", toolPath, msg.configPath), + }) + + // Launch the tool with the kubeconfig + var c *exec.Cmd + if tool == "k9s" { + c = exec.Command(toolPath, "--kubeconfig", msg.configPath) + c.Stdin = os.Stdin + c.Stdout = os.Stdout + c.Stderr = os.Stderr + } else { + // For kubectl, launch an interactive shell with KUBECONFIG set + // This allows users to run multiple kubectl commands + shell := os.Getenv("SHELL") + if shell == "" { + shell = "/bin/sh" + } + + // Create a welcome message that displays in the shell + welcomeMsg := fmt.Sprintf(`echo "" +echo "☸️ OVHcloud Kubernetes Shell" +echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" +echo "📁 KUBECONFIG=%s" +echo "" +echo "💡 You can now run kubectl commands:" +echo " kubectl get nodes" +echo " kubectl get pods -A" +echo " kubectl cluster-info" +echo "" +echo "Type 'exit' to return to the browser." +echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" +echo "" +exec %s`, msg.configPath, shell) + + c = exec.Command(shell, "-c", welcomeMsg) + c.Env = append(os.Environ(), "KUBECONFIG="+msg.configPath) + c.Stdin = os.Stdin + c.Stdout = os.Stdout + c.Stderr = os.Stderr + } + + return m, tea.ExecProcess(c, func(err error) tea.Msg { + return kubeToolLaunchMsg{tool: tool, err: err} + }) +} + +// handleKubeToolLaunch handles the result of kubectl/k9s execution +func (m Model) handleKubeToolLaunch(msg kubeToolLaunchMsg) (tea.Model, tea.Cmd) { + if msg.err != nil { + // Don't treat normal exit as error + if exitErr, ok := msg.err.(*exec.ExitError); ok { + if exitErr.ExitCode() == 0 { + m.notification = fmt.Sprintf("✅ %s session ended", msg.tool) + } else { + m.notification = fmt.Sprintf("⚠️ %s exited with code %d", msg.tool, exitErr.ExitCode()) + } + } else { + m.notification = fmt.Sprintf("❌ %s error: %s", msg.tool, msg.err) + } + } else { + m.notification = fmt.Sprintf("✅ %s session ended", msg.tool) + } + m.notificationExpiry = time.Now().Add(5 * time.Second) + + return m, tea.Tick(5*time.Second, func(t time.Time) tea.Msg { + return clearNotificationMsg{} + }) +} + +// handleKubeAction handles the result of Kubernetes actions (non-tool actions) +func (m Model) handleKubeAction(msg kubeActionMsg) (tea.Model, tea.Cmd) { + actionNames := map[string]string{ + "kubeconfig": "Generate Kubeconfig", + "kubectl": "kubectl", + "k9s": "k9s", + "reset_kubeconfig": "Reset Kubeconfig", + "add_my_ip": "Add My IP", + } + + actionName := actionNames[msg.action] + if actionName == "" { + actionName = msg.action + } + + if msg.err != nil { + m.notification = fmt.Sprintf("❌ %s failed: %s", actionName, msg.err) + } else { + if msg.action == "kubeconfig" && msg.configPath != "" { + m.notification = fmt.Sprintf("✅ Kubeconfig saved to: %s", msg.configPath) + } else if msg.action == "reset_kubeconfig" { + m.notification = fmt.Sprintf("✅ Kubeconfig reset for cluster '%s'. Nodes will be reinstalled.", msg.clusterName) + } else if msg.action == "add_my_ip" && msg.configPath != "" { + m.notification = fmt.Sprintf("✅ Added your IP (%s) to cluster IP restrictions", msg.configPath) + } else { + m.notification = fmt.Sprintf("✅ %s completed successfully!", actionName) + } + } + m.notificationExpiry = time.Now().Add(5 * time.Second) + + return m, tea.Tick(5*time.Second, func(t time.Time) tea.Msg { + return clearNotificationMsg{} + }) +} + +// handleIPRestrictionCheck handles the result of checking IP restrictions +func (m Model) handleIPRestrictionCheck(msg ipRestrictionCheckMsg) (tea.Model, tea.Cmd) { + if msg.err != nil { + m.notification = fmt.Sprintf("❌ Failed to check IP restrictions: %s", msg.err) + m.notificationExpiry = time.Now().Add(5 * time.Second) + return m, tea.Tick(5*time.Second, func(t time.Time) tea.Msg { + return clearNotificationMsg{} + }) + } + + if msg.needsAdd { + // Show confirmation dialog + m.ipAddConfirm = true + m.pendingKubeAction = msg.actionIndex + m.pendingIPCIDR = msg.ipCIDR + m.actionConfirm = false // Reset action confirm + return m, nil + } + + // IP is already in the list, proceed with action + return m, nil +} + +// handleIPRestrictionsLoaded processes loaded IP restrictions +func (m Model) handleIPRestrictionsLoaded(msg ipRestrictionsLoadedMsg) (tea.Model, tea.Cmd) { + if msg.err != nil { + m.notification = fmt.Sprintf("❌ %s", msg.err) + m.notificationExpiry = time.Now().Add(5 * time.Second) + m.mode = DetailView + return m, tea.Tick(5*time.Second, func(t time.Time) tea.Msg { + return clearNotificationMsg{} + }) + } + + m.ipRestrictions = msg.ips + m.ipRestrictionsIdx = 0 + m.ipRestrictionsMode = "list" + m.ipRestrictionsInput = "" + m.mode = IPRestrictionsView + return m, nil +} + +// handleIPRestrictionsUpdated processes the result of IP restrictions update +func (m Model) handleIPRestrictionsUpdated(msg ipRestrictionsUpdatedMsg) (tea.Model, tea.Cmd) { + if msg.err != nil { + m.notification = fmt.Sprintf("❌ %s", msg.err) + m.notificationExpiry = time.Now().Add(5 * time.Second) + m.ipRestrictionsMode = "list" + return m, tea.Tick(5*time.Second, func(t time.Time) tea.Msg { + return clearNotificationMsg{} + }) + } + + // Success notification + switch msg.action { + case "add": + m.notification = fmt.Sprintf("✅ Added IP: %s", msg.ip) + case "edit": + m.notification = fmt.Sprintf("✅ Updated IP: %s", msg.ip) + case "delete": + m.notification = fmt.Sprintf("✅ Removed IP: %s", msg.ip) + default: + m.notification = "✅ IP restrictions updated" + } + m.notificationExpiry = time.Now().Add(3 * time.Second) + + // Refresh the IP restrictions list + return m, tea.Batch( + m.fetchIPRestrictions(), + tea.Tick(3*time.Second, func(t time.Time) tea.Msg { + return clearNotificationMsg{} + }), + ) +} + // View renders the UI func (m Model) View() string { var content strings.Builder @@ -707,6 +994,19 @@ func (m Model) renderContentBox(width int) string { return contentBoxStyle.Width(width - 4).Render(fullContent) } + // Handle IP restrictions view with special title + if m.mode == IPRestrictionsView { + clusterName := "" + if m.detailData != nil { + clusterName = getStringValue(m.detailData, "name", "") + } + titleText = fmt.Sprintf(" 🔒 IP Restrictions - %s ", clusterName) + title := productTitleStyle.Render(titleText) + contentStr := m.renderIPRestrictionsView(width - 6) + fullContent := title + "\n\n" + contentStr + return contentBoxStyle.Width(width - 4).Render(fullContent) + } + // Handle project selection view specially if m.mode == ProjectSelectView || m.currentProduct == ProductProjects { titleText = " 📦 Select a Project " @@ -893,6 +1193,82 @@ func (m Model) renderDebugView(width int) string { return content.String() } +// renderIPRestrictionsView displays the IP restrictions management view +func (m Model) renderIPRestrictionsView(width int) string { + var content strings.Builder + + headerStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#FFFFFF")) + itemStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#888888")) + selectedStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#00FF7F")).Bold(true).Background(lipgloss.Color("#2a2a2a")) + infoStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#666666")).Italic(true) + inputStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#7B68EE")) + + // Check if restrictions are enabled + if len(m.ipRestrictions) == 0 && m.ipRestrictionsMode == "list" { + content.WriteString(infoStyle.Render(" ℹ️ IP restrictions are not enabled for this cluster.") + "\n") + content.WriteString(infoStyle.Render(" The API server is accessible from any IP address.") + "\n\n") + content.WriteString(infoStyle.Render(" To enable IP restrictions, add at least one IP/CIDR.") + "\n\n") + } + + // Mode-specific rendering + switch m.ipRestrictionsMode { + case "add": + content.WriteString(headerStyle.Render(" Add IP/CIDR:") + "\n\n") + content.WriteString(infoStyle.Render(" Enter an IP address or CIDR range (e.g., 203.0.113.50/32 or 10.0.0.0/8)") + "\n\n") + content.WriteString(inputStyle.Render(fmt.Sprintf(" > %s▌", m.ipRestrictionsInput)) + "\n\n") + content.WriteString(itemStyle.Render(" Enter: Confirm • Esc: Cancel • 'm': Add my current IP")) + return content.String() + + case "edit": + content.WriteString(headerStyle.Render(" Edit IP/CIDR:") + "\n\n") + content.WriteString(infoStyle.Render(fmt.Sprintf(" Editing: %s", m.ipRestrictionsEditIP)) + "\n\n") + content.WriteString(inputStyle.Render(fmt.Sprintf(" > %s▌", m.ipRestrictionsInput)) + "\n\n") + content.WriteString(itemStyle.Render(" Enter: Confirm • Esc: Cancel")) + return content.String() + } + + // List mode + content.WriteString(headerStyle.Render(" Allowed IP addresses/ranges:") + "\n\n") + + if len(m.ipRestrictions) == 0 { + content.WriteString(itemStyle.Render(" (none - add IPs to enable restrictions)") + "\n\n") + } else { + // Display IPs with selection + maxVisible := 12 + startIdx := 0 + if m.ipRestrictionsIdx >= maxVisible { + startIdx = m.ipRestrictionsIdx - maxVisible + 1 + } + endIdx := startIdx + maxVisible + if endIdx > len(m.ipRestrictions) { + endIdx = len(m.ipRestrictions) + } + + for i := startIdx; i < endIdx; i++ { + ip := m.ipRestrictions[i] + if i == m.ipRestrictionsIdx { + content.WriteString(selectedStyle.Render(fmt.Sprintf(" ▶ %s", ip)) + "\n") + } else { + content.WriteString(itemStyle.Render(fmt.Sprintf(" %s", ip)) + "\n") + } + } + + // Scroll indicator + if len(m.ipRestrictions) > maxVisible { + scrollInfo := fmt.Sprintf("\n Showing %d-%d of %d IPs", startIdx+1, endIdx, len(m.ipRestrictions)) + content.WriteString(infoStyle.Render(scrollInfo) + "\n") + } + } + + content.WriteString("\n") + + // Actions help + helpStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#666666")) + content.WriteString(helpStyle.Render(" a: Add IP • m: Add my IP • e: Edit selected • Del: Remove selected • Esc: Back")) + + return content.String() +} + // renderEmptyView displays an empty state with creation prompt func (m Model) renderEmptyView() string { var content strings.Builder @@ -2059,9 +2435,38 @@ func (m Model) renderKubernetesDetail(width int) string { configBox := renderBox("Configuration", configContent.String(), boxWidth) - // Actions - actionsContent := "[kubectl config] [Scale] [Upgrade] [Reset Kubeconfig]" - actionsBox := renderBox("Actions", actionsContent, width-4) + // Actions box with selectable actions (like instances) + actions := []string{"Kubeconfig", "kubectl", "k9s", "Reset Kubeconfig", "Add My IP", "IP Restrictions"} + var actionParts []string + for i, action := range actions { + if i == m.selectedAction { + // Selected action - highlighted + actionParts = append(actionParts, lipgloss.NewStyle(). + Background(lipgloss.Color("#7B68EE")). + Foreground(lipgloss.Color("#FFFFFF")). + Bold(true). + Padding(0, 1). + Render(action)) + } else { + actionParts = append(actionParts, lipgloss.NewStyle(). + Foreground(lipgloss.Color("#888888")). + Padding(0, 1). + Render("["+action+"]")) + } + } + actionsContent := strings.Join(actionParts, " ") + if m.ipAddConfirm { + actionsContent += "\n\n" + lipgloss.NewStyle(). + Foreground(lipgloss.Color("#FFD700")). + Bold(true). + Render(fmt.Sprintf("⚠️ Your IP (%s) is not in restrictions. Add it? [y/n]", m.pendingIPCIDR)) + } else if m.actionConfirm { + actionsContent += "\n\n" + lipgloss.NewStyle(). + Foreground(lipgloss.Color("#FFD700")). + Bold(true). + Render(fmt.Sprintf("⚠️ Press Enter to confirm %s, Escape to cancel", actions[m.selectedAction])) + } + actionsBox := renderBox("Actions (←/→ to navigate, Enter to execute)", actionsContent, width-4) content.WriteString(actionsBox) content.WriteString("\n\n") @@ -2218,6 +2623,14 @@ func (m Model) renderFooter() string { help = "Type instance name to confirm • Enter: Delete • Esc: Cancel" case DebugView: help = "↑↓: Scroll • c: Clear logs • d/Esc: Close • q: Quit" + case IPRestrictionsView: + if m.ipRestrictionsMode == "add" { + help = "Type IP/CIDR • Enter: Add • m: Add my IP • Esc: Cancel" + } else if m.ipRestrictionsMode == "edit" { + help = "Type IP/CIDR • Enter: Save • Esc: Cancel" + } else { + help = "↑↓: Navigate • a: Add • m: Add my IP • e: Edit • Del: Remove • Esc: Back" + } default: help = "Enter: Select • q: Quit" } @@ -2252,6 +2665,11 @@ func (m Model) handleKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m.handleDebugKeyPress(msg) } + // Handle IP restrictions view mode + if m.mode == IPRestrictionsView { + return m.handleIPRestrictionsKeyPress(msg) + } + // Handle filter mode if m.filterMode { return m.handleFilterKeyPress(msg) @@ -2279,6 +2697,14 @@ func (m Model) handleKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } return m, nil } + // In DetailView for Kubernetes, navigate actions + if m.mode == DetailView && m.currentProduct == ProductKubernetes { + if m.selectedAction > 0 { + m.selectedAction-- + m.actionConfirm = false + } + return m, nil + } // Navigate to previous product (only when not in project selection) if m.mode != ProjectSelectView && m.currentProduct != ProductProjects { if m.navIdx > 0 { @@ -2297,6 +2723,14 @@ func (m Model) handleKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } return m, nil } + // In DetailView for Kubernetes, navigate actions + if m.mode == DetailView && m.currentProduct == ProductKubernetes { + if m.selectedAction < 5 { // 6 actions: 0-5 (Kubeconfig, kubectl, k9s, Reset, Add My IP, IP Restrictions) + m.selectedAction++ + m.actionConfirm = false + } + return m, nil + } // Navigate to next product (only when not in project selection) if m.mode != ProjectSelectView && m.currentProduct != ProductProjects { navItems := getNavItems() @@ -2334,6 +2768,11 @@ func (m Model) handleKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } // Go back to table view from detail view, or cancel action confirm if m.mode == DetailView { + if m.ipAddConfirm { + // Cancel IP add confirmation - proceed with action anyway + m.ipAddConfirm = false + return m, m.executeKubernetesActionSkipIPCheck(m.pendingKubeAction) + } if m.actionConfirm { m.actionConfirm = false } else { @@ -2343,6 +2782,25 @@ func (m Model) handleKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } return m, nil + case "y", "Y": + // Handle 'y' for IP restriction confirmation + if m.mode == DetailView && m.currentProduct == ProductKubernetes && m.ipAddConfirm { + // User confirmed - add IP and proceed with action + m.ipAddConfirm = false + return m, m.executeKubeActionWithIPAdd(m.pendingKubeAction, m.pendingIPCIDR) + } + return m, nil + + case "n", "N": + // Handle 'n' for IP restriction confirmation - skip adding but proceed + if m.mode == DetailView && m.currentProduct == ProductKubernetes && m.ipAddConfirm { + m.ipAddConfirm = false + m.notification = "Continuing without adding IP..." + m.notificationExpiry = time.Now().Add(3 * time.Second) + return m, m.executeKubernetesActionSkipIPCheck(m.pendingKubeAction) + } + return m, nil + case "enter": // Handle enter based on current mode if m.mode == DetailView && m.currentProduct == ProductInstances { @@ -2356,6 +2814,27 @@ func (m Model) handleKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.actionConfirm = true return m, nil } + } else if m.mode == DetailView && m.currentProduct == ProductKubernetes { + // Handle IP restriction confirmation + if m.ipAddConfirm { + // User confirmed with Enter - add IP and proceed + m.ipAddConfirm = false + return m, m.executeKubeActionWithIPAdd(m.pendingKubeAction, m.pendingIPCIDR) + } + // IP Restrictions action (index 5) doesn't need confirmation - it just opens a view + if m.selectedAction == 5 { + return m, m.executeKubernetesAction(m.selectedAction) + } + // Execute selected action on Kubernetes cluster + if m.actionConfirm { + // Confirmed - execute the action + m.actionConfirm = false + return m, m.executeKubernetesAction(m.selectedAction) + } else { + // Ask for confirmation + m.actionConfirm = true + return m, nil + } } else if m.mode == ProjectSelectView { // Select project and go to products view selectedRow := m.table.Cursor() @@ -2490,6 +2969,124 @@ func (m Model) handleDebugKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, nil } +// handleIPRestrictionsKeyPress handles key presses in IP restrictions view +func (m Model) handleIPRestrictionsKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + key := msg.String() + + // Handle input modes (add/edit) + if m.ipRestrictionsMode == "add" || m.ipRestrictionsMode == "edit" { + switch key { + case "esc": + // Cancel and go back to list + m.ipRestrictionsMode = "list" + m.ipRestrictionsInput = "" + return m, nil + + case "enter": + if m.ipRestrictionsInput == "" { + return m, nil + } + if m.ipRestrictionsMode == "add" { + // Add the new IP + return m, m.addIPRestriction(m.ipRestrictionsInput) + } else { + // Edit the IP + return m, m.editIPRestriction(m.ipRestrictionsEditIP, m.ipRestrictionsInput) + } + + case "m": + // Add my IP shortcut (only in add mode) + if m.ipRestrictionsMode == "add" { + return m, m.addMyIPRestriction() + } + return m, nil + + case "backspace": + if len(m.ipRestrictionsInput) > 0 { + m.ipRestrictionsInput = m.ipRestrictionsInput[:len(m.ipRestrictionsInput)-1] + } + return m, nil + + default: + // Accept valid IP/CIDR characters + if len(key) == 1 { + c := key[0] + // Allow digits, dots, colons (IPv6), slashes (CIDR), and hex letters + if (c >= '0' && c <= '9') || c == '.' || c == ':' || c == '/' || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F') { + m.ipRestrictionsInput += key + } + } + return m, nil + } + } + + // List mode + switch key { + case "esc": + // Go back to detail view + m.mode = DetailView + m.ipRestrictions = nil + m.ipRestrictionsIdx = 0 + m.ipRestrictionsMode = "list" + return m, nil + + case "q", "ctrl+c": + return m, tea.Quit + + case "up", "k": + if m.ipRestrictionsIdx > 0 { + m.ipRestrictionsIdx-- + } + return m, nil + + case "down", "j": + if m.ipRestrictionsIdx < len(m.ipRestrictions)-1 { + m.ipRestrictionsIdx++ + } + return m, nil + + case "a": + // Enter add mode + m.ipRestrictionsMode = "add" + m.ipRestrictionsInput = "" + return m, nil + + case "m": + // Add my IP directly + return m, m.addMyIPRestriction() + + case "e": + // Enter edit mode for selected IP + if len(m.ipRestrictions) > 0 && m.ipRestrictionsIdx < len(m.ipRestrictions) { + m.ipRestrictionsMode = "edit" + m.ipRestrictionsEditIP = m.ipRestrictions[m.ipRestrictionsIdx] + m.ipRestrictionsInput = m.ipRestrictionsEditIP + } + return m, nil + + case "delete", "backspace", "x": + // Delete selected IP + if len(m.ipRestrictions) > 0 && m.ipRestrictionsIdx < len(m.ipRestrictions) { + ipToDelete := m.ipRestrictions[m.ipRestrictionsIdx] + // Adjust index if we're deleting the last item + if m.ipRestrictionsIdx >= len(m.ipRestrictions)-1 && m.ipRestrictionsIdx > 0 { + m.ipRestrictionsIdx-- + } + return m, m.deleteIPRestriction(ipToDelete) + } + return m, nil + + case "d": + // Open debug view + m.previousMode = m.mode + m.mode = DebugView + m.debugScrollOffset = 0 + return m, nil + } + + return m, nil +} + // handleDeleteConfirmKeyPress handles key presses in delete confirmation mode func (m Model) handleDeleteConfirmKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) { key := msg.String() diff --git a/internal/services/cloud/cloud_kube.go b/internal/services/cloud/cloud_kube.go index bbfa6217..71cd2bde 100644 --- a/internal/services/cloud/cloud_kube.go +++ b/internal/services/cloud/cloud_kube.go @@ -5,12 +5,15 @@ package cloud import ( + "bufio" _ "embed" "encoding/json" "errors" "fmt" "log" "net/url" + "os" + "os/exec" "strings" "github.com/ovh/ovhcloud-cli/internal/assets" @@ -823,3 +826,333 @@ func UpdateKubeLoadBalancersSubnet(_ *cobra.Command, args []string) { display.OutputInfo(&flags.OutputFormatConfig, nil, "✅ Load balancers subnet updated successfully") } + +// LaunchK9s generates kubeconfig and launches k9s for the given cluster +func LaunchK9s(_ *cobra.Command, args []string) { + clusterID := args[0] + + // Check if k9s is installed first (fail fast) + k9sPath, err := exec.LookPath("k9s") + if err != nil { + display.OutputError(&flags.OutputFormatConfig, "k9s not found. Install with: brew install k9s (or https://k9scli.io/)") + return + } + + // Check IP restrictions before launching + checkAndOfferIPRestriction(clusterID) + + configPath, err := generateAndSaveKubeconfig(clusterID) + if err != nil { + display.OutputError(&flags.OutputFormatConfig, "%s", err) + return + } + + fmt.Printf("☸️ Launching k9s with kubeconfig: %s\n", configPath) + + // Launch k9s with the kubeconfig + cmd := exec.Command(k9sPath) + cmd.Env = append(os.Environ(), "KUBECONFIG="+configPath) + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + + if err := cmd.Run(); err != nil { + display.OutputError(&flags.OutputFormatConfig, "k9s exited with error: %s", err) + } +} + +// LaunchKubeShell generates kubeconfig and launches an interactive shell with kubectl access +func LaunchKubeShell(_ *cobra.Command, args []string) { + clusterID := args[0] + + // Check if kubectl is installed (warn but don't fail) + if _, err := exec.LookPath("kubectl"); err != nil { + fmt.Println("⚠️ kubectl not found in PATH. Install from: https://kubernetes.io/docs/tasks/tools/") + fmt.Println("") + } + + // Check IP restrictions before launching + checkAndOfferIPRestriction(clusterID) + + configPath, err := generateAndSaveKubeconfig(clusterID) + if err != nil { + display.OutputError(&flags.OutputFormatConfig, "%s", err) + return + } + + // Determine the user's shell + shell := os.Getenv("SHELL") + if shell == "" { + shell = "/bin/sh" + } + + fmt.Println("") + fmt.Println("☸️ OVHcloud Kubernetes Shell") + fmt.Println("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") + fmt.Printf("📁 KUBECONFIG=%s\n", configPath) + fmt.Println("") + fmt.Println("💡 You can now run kubectl commands:") + fmt.Println(" kubectl get nodes") + fmt.Println(" kubectl get pods -A") + fmt.Println(" kubectl cluster-info") + fmt.Println("") + fmt.Println("Type 'exit' to return.") + fmt.Println("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") + fmt.Println("") + + // Launch shell with KUBECONFIG set + cmd := exec.Command(shell) + cmd.Env = append(os.Environ(), "KUBECONFIG="+configPath) + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + + if err := cmd.Run(); err != nil { + display.OutputError(&flags.OutputFormatConfig, "shell exited with error: %s", err) + } +} + +// generateAndSaveKubeconfig fetches kubeconfig from API and saves it to ~/.kube/ +func generateAndSaveKubeconfig(clusterID string) (string, error) { + projectID, err := getConfiguredCloudProject() + if err != nil { + return "", err + } + + // Fetch kubeconfig from API + endpoint := fmt.Sprintf("/v1/cloud/project/%s/kube/%s/kubeconfig", projectID, url.PathEscape(clusterID)) + var kubeConfig map[string]any + if err := httpLib.Client.Post(endpoint, nil, &kubeConfig); err != nil { + return "", fmt.Errorf("failed to generate kubeconfig: %w", err) + } + + content, ok := kubeConfig["content"].(string) + if !ok || content == "" { + return "", fmt.Errorf("kubeconfig content not found in response") + } + + // Get cluster name for the filename + clusterEndpoint := fmt.Sprintf("/v1/cloud/project/%s/kube/%s", projectID, url.PathEscape(clusterID)) + var cluster map[string]any + clusterName := clusterID[:8] // fallback to first 8 chars of ID + if err := httpLib.Client.Get(clusterEndpoint, &cluster); err == nil { + if name, ok := cluster["name"].(string); ok && name != "" { + clusterName = sanitizeKubeFilename(name) + } + } + + // Ensure ~/.kube directory exists + homeDir, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("failed to get home directory: %w", err) + } + + kubeDir := fmt.Sprintf("%s/.kube", homeDir) + if err := os.MkdirAll(kubeDir, 0755); err != nil { + return "", fmt.Errorf("failed to create .kube directory: %w", err) + } + + // Save kubeconfig to file + configPath := fmt.Sprintf("%s/ovhcloud-%s.yaml", kubeDir, clusterName) + if err := os.WriteFile(configPath, []byte(content), 0600); err != nil { + return "", fmt.Errorf("failed to write kubeconfig: %w", err) + } + + return configPath, nil +} + +// sanitizeKubeFilename removes or replaces characters not suitable for filenames +func sanitizeKubeFilename(name string) string { + var result strings.Builder + for _, r := range name { + if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '-' || r == '_' { + result.WriteRune(r) + } else if r == ' ' { + result.WriteRune('-') + } + } + + // Convert to lowercase and trim + sanitized := strings.ToLower(strings.Trim(result.String(), "-_")) + + // Limit length + if len(sanitized) > 50 { + sanitized = sanitized[:50] + } + + return sanitized +} + +// GetPublicIP detects the client's public IPv4 address using OVH's geolocation API +// Note: Only IPv4 is supported by OVH Managed Kubernetes API server +func GetPublicIP() (string, error) { + var geolocation struct { + Continent string `json:"continent"` + CountryCode string `json:"countryCode"` + IP string `json:"ip"` + } + + if err := httpLib.Client.Post("/v1/me/geolocation", nil, &geolocation); err != nil { + return "", fmt.Errorf("failed to detect public IP: %w", err) + } + + if geolocation.IP == "" { + return "", fmt.Errorf("failed to detect public IP: empty response") + } + + // Ensure we got an IPv4 address (contains dots, no colons) + if !isIPv4(geolocation.IP) { + return "", fmt.Errorf("failed to detect public IPv4: got IPv6 address %s", geolocation.IP) + } + + return geolocation.IP, nil +} + +// isIPv4 checks if the given string is a valid IPv4 address +func isIPv4(ip string) bool { + // Simple check: IPv4 addresses contain dots and no colons + return strings.Contains(ip, ".") && !strings.Contains(ip, ":") +} + +// checkAndOfferIPRestriction checks if the user's IP is in the cluster's IP restrictions +// and offers to add it if not. This is called before launching kubectl/k9s/shell. +func checkAndOfferIPRestriction(clusterID string) { + projectID, err := getConfiguredCloudProject() + if err != nil { + return // Silently skip if we can't get project ID + } + + // Fetch current IP restrictions + endpoint := fmt.Sprintf("/v1/cloud/project/%s/kube/%s/ipRestrictions", projectID, url.PathEscape(clusterID)) + currentIPs, err := httpLib.FetchArray(endpoint, "") + if err != nil { + return // Silently skip if we can't fetch restrictions + } + + // If no restrictions are enabled, nothing to check + if len(currentIPs) == 0 { + return + } + + // Get public IP + publicIP, err := GetPublicIP() + if err != nil { + return // Silently skip if we can't detect IP + } + + ipCIDR := publicIP + "/32" + + // Check if IP is already in the list + for _, ip := range currentIPs { + ipStr, ok := ip.(string) + if !ok { + continue + } + if ipStr == ipCIDR || ipStr == publicIP { + return // IP is already in the list, nothing to do + } + } + + // IP is not in the list, ask user if they want to add it + fmt.Printf("\n⚠️ Your IP (%s) is not in the cluster's IP restrictions.\n", ipCIDR) + fmt.Printf(" You may not be able to connect to the cluster API.\n\n") + fmt.Printf("Would you like to add your IP to the restrictions? [y/N]: ") + + reader := bufio.NewReader(os.Stdin) + response, err := reader.ReadString('\n') + if err != nil { + return + } + + response = strings.TrimSpace(strings.ToLower(response)) + if response != "y" && response != "yes" { + fmt.Println("Continuing without adding IP...") + return + } + + // Build the new IP list + ipList := make([]string, 0, len(currentIPs)+1) + for _, ip := range currentIPs { + if ipStr, ok := ip.(string); ok { + ipList = append(ipList, ipStr) + } + } + ipList = append(ipList, ipCIDR) + + // Update the restrictions + body := map[string]any{ + "ips": ipList, + } + + if err := httpLib.Client.Put(endpoint, body, nil); err != nil { + fmt.Printf("❌ Failed to add IP: %s\n", err) + fmt.Println("Continuing anyway...") + return + } + + fmt.Printf("✅ Added your IP (%s) to cluster IP restrictions.\n\n", ipCIDR) +} + +// AddMyIPToKubeRestrictions adds the client's public IP to the cluster's IP restrictions +func AddMyIPToKubeRestrictions(_ *cobra.Command, args []string) { + projectID, err := getConfiguredCloudProject() + if err != nil { + display.OutputError(&flags.OutputFormatConfig, "%s", err) + return + } + + clusterID := args[0] + + // Fetch current IP restrictions + endpoint := fmt.Sprintf("/v1/cloud/project/%s/kube/%s/ipRestrictions", projectID, url.PathEscape(clusterID)) + currentIPs, err := httpLib.FetchArray(endpoint, "") + if err != nil { + display.OutputError(&flags.OutputFormatConfig, "failed to fetch IP restrictions: %s", err) + return + } + + // Check if restrictions are enabled (non-empty list) + if len(currentIPs) == 0 { + display.OutputError(&flags.OutputFormatConfig, "IP restrictions are not enabled for this cluster. Enable them first using 'ovhcloud cloud kube ip-restrictions edit'") + return + } + + // Get public IP + publicIP, err := GetPublicIP() + if err != nil { + display.OutputError(&flags.OutputFormatConfig, "failed to detect your public IP: %s", err) + return + } + + // Add /32 suffix for single IP + ipCIDR := publicIP + "/32" + + // Check if IP is already in the list + ipList := make([]string, 0, len(currentIPs)+1) + for _, ip := range currentIPs { + ipStr, ok := ip.(string) + if !ok { + continue + } + if ipStr == ipCIDR || ipStr == publicIP { + display.OutputInfo(&flags.OutputFormatConfig, nil, "Your IP (%s) is already in the restrictions list", ipCIDR) + return + } + ipList = append(ipList, ipStr) + } + + // Append the new IP + ipList = append(ipList, ipCIDR) + + // Update the restrictions + body := map[string]any{ + "ips": ipList, + } + + if err := httpLib.Client.Put(endpoint, body, nil); err != nil { + display.OutputError(&flags.OutputFormatConfig, "failed to update IP restrictions: %s", err) + return + } + + display.OutputInfo(&flags.OutputFormatConfig, nil, "✅ Added your IP (%s) to cluster IP restrictions", ipCIDR) +}