Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/.vitepress/config.mts
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@ export default defineConfig({
{ text: "Resource History", link: "/guide/resource-history" },
{ text: "Custom Sidebar", link: "/guide/custom-sidebar" },
{ text: "Kube Proxy", link: "/guide/kube-proxy" },
{ text: "Kubeconfig from Secret", link: "/guide/kubeconfig-from-secret" },
],
},
{
Expand Down
127 changes: 127 additions & 0 deletions docs/guide/kubeconfig-from-secret.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
# Kubeconfig from Kubernetes Secret

This feature allows Kite to automatically read kubeconfigs from Kubernetes secrets instead of storing them in the database.

## Overview

Kite's secret reference feature supports:

- **Dynamic configuration**: Kubeconfigs are automatically reloaded during the synchronization cycle (every minute)
- **Centralized management**: Use tools like FluxCD or ArgoCD to manage your secrets
- **Enhanced security**: Kubeconfigs remain in Kubernetes and are not stored in the database
- **Automatic updates**: Kite detects changes to secret content and reloads clients

## Prerequisites

- Kite must be deployed in `in-cluster` mode (with access to the Kubernetes API)
- Kite's ServiceAccount already has read permissions on secrets (no additional RBAC configuration needed)

## Usage

### 1. Create a Secret with Your Kubeconfig or use an existing one in next steps

With kubectl:
```bash
kubectl create secret generic kubeconfig-prod \
--from-file=config=/path/to/kubeconfig \
-n kite-system
```

Or with a YAML manifest:

```yaml
apiVersion: v1
kind: Secret
metadata:
name: kubeconfig-prod
namespace: kite-system
type: Opaque
stringData:
config: |
apiVersion: v1
kind: Config
clusters:
- cluster:
server: https://prod-cluster.example.com
certificate-authority-data: LS0t...
name: prod-cluster
contexts:
- context:
cluster: prod-cluster
user: admin
name: prod-context
current-context: prod-context
users:
- name: admin
user:
client-certificate-data: LS0t...
client-key-data: LS0t...
```

### 2. Create the Cluster in Kite via API

```bash
curl -X POST http://kite-url/api/v1/clusters \
-H "Content-Type: application/json" \
-d '{
"name": "production",
"description": "Production cluster",
"secret_name": "kubeconfig-prod",
"secret_namespace": "kite-system",
"secret_key": "config",
"enable": true
}'
```

### 3. Or via the Web Interface

In the Kite interface, when adding a cluster:
- **Name**: `production`
- **Secret Name**: `kubeconfig-prod`
- **Secret Namespace**: `kite-system`
- **Secret Key**: `config`
- Leave the **Config** field empty

## How It Works

1. **Automatic synchronization**: Kite checks every minute if the secret content has changed
2. **Change detection**: If the content changes, Kite automatically reloads the Kubernetes client
3. **Fallback**: If the `secret_name` field is empty, Kite uses the classic `config` field

## Limitations

- The secret must be in a namespace accessible by Kite's ServiceAccount
- Change detection delay is 1 minute (sync cycle)
- Kite must be in `in-cluster` mode to access secrets

## Troubleshooting

### Logs

Check Kite logs to see synchronization messages:

```bash
kubectl logs -n kite-system deployment/kite | grep -i secret
```

You should see:
```
Reading kubeconfig for cluster production from secret kite-system/kubeconfig-prod:config
```

### Common Issues

**`failed to get secret: secrets "kubeconfig-prod" is forbidden`**

Check the RBAC permissions of the ServiceAccount.

**`key config not found in secret`**

Verify that the key exists in the secret:
```bash
kubectl get secret kubeconfig-prod -n kite-system -o jsonpath='{.data}'
```

**`failed to get in-cluster config`**

Kite is not deployed in in-cluster mode or the ServiceAccount is not mounted.
62 changes: 37 additions & 25 deletions pkg/cluster/cluster_handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -81,12 +81,15 @@ func (cm *ClusterManager) GetClusterList(c *gin.Context) {

func (cm *ClusterManager) CreateCluster(c *gin.Context) {
var req struct {
Name string `json:"name" binding:"required"`
Description string `json:"description"`
Config string `json:"config"`
PrometheusURL string `json:"prometheusURL"`
InCluster bool `json:"inCluster"`
IsDefault bool `json:"isDefault"`
Name string `json:"name" binding:"required"`
Description string `json:"description"`
Config string `json:"config"`
PrometheusURL string `json:"prometheusURL"`
InCluster bool `json:"inCluster"`
IsDefault bool `json:"isDefault"`
SecretName string `json:"secret_name"`
SecretNamespace string `json:"secret_namespace"`
SecretKey string `json:"secret_key"`
}

if err := c.ShouldBindJSON(&req); err != nil {
Expand All @@ -110,13 +113,16 @@ func (cm *ClusterManager) CreateCluster(c *gin.Context) {
}

cluster := &model.Cluster{
Name: req.Name,
Description: req.Description,
Config: model.SecretString(req.Config),
PrometheusURL: req.PrometheusURL,
InCluster: req.InCluster,
IsDefault: req.IsDefault,
Enable: true,
Name: req.Name,
Description: req.Description,
Config: model.SecretString(req.Config),
PrometheusURL: req.PrometheusURL,
InCluster: req.InCluster,
IsDefault: req.IsDefault,
Enable: true,
SecretName: req.SecretName,
SecretNamespace: req.SecretNamespace,
SecretKey: req.SecretKey,
}

if err := model.AddCluster(cluster); err != nil {
Expand All @@ -141,13 +147,16 @@ func (cm *ClusterManager) UpdateCluster(c *gin.Context) {
}

var req struct {
Name string `json:"name"`
Description string `json:"description"`
Config string `json:"config"`
PrometheusURL string `json:"prometheusURL"`
InCluster bool `json:"inCluster"`
IsDefault bool `json:"isDefault"`
Enabled bool `json:"enabled"`
Name string `json:"name"`
Description string `json:"description"`
Config string `json:"config"`
PrometheusURL string `json:"prometheusURL"`
InCluster bool `json:"inCluster"`
IsDefault bool `json:"isDefault"`
Enabled bool `json:"enabled"`
SecretName string `json:"secret_name"`
SecretNamespace string `json:"secret_namespace"`
SecretKey string `json:"secret_key"`
}

if err := c.ShouldBindJSON(&req); err != nil {
Expand All @@ -173,11 +182,14 @@ func (cm *ClusterManager) UpdateCluster(c *gin.Context) {
}

updates := map[string]interface{}{
"description": req.Description,
"prometheus_url": req.PrometheusURL,
"in_cluster": req.InCluster,
"is_default": req.IsDefault,
"enable": req.Enabled,
"description": req.Description,
"prometheus_url": req.PrometheusURL,
"in_cluster": req.InCluster,
"is_default": req.IsDefault,
"enable": req.Enabled,
"secret_name": req.SecretName,
"secret_namespace": req.SecretNamespace,
"secret_key": req.SecretKey,
}

if req.Name != "" && req.Name != cluster.Name {
Expand Down
88 changes: 86 additions & 2 deletions pkg/cluster/cluster_manager.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package cluster

import (
"context"
"errors"
"fmt"
"net/http"
Expand All @@ -12,6 +13,8 @@ import (
"github.com/zxh326/kite/pkg/model"
"github.com/zxh326/kite/pkg/prometheus"
"gorm.io/gorm"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/client-go/kubernetes"
"k8s.io/client-go/rest"
"k8s.io/client-go/tools/clientcmd"
clientcmdapi "k8s.io/client-go/tools/clientcmd/api"
Expand All @@ -27,6 +30,7 @@ type ClientSet struct {
DiscoveredPrometheusURL string
config string
prometheusURL string
secretRef string // Format: "namespace/name:key" to track secret reference
}

type ClusterManager struct {
Expand All @@ -45,6 +49,10 @@ func createClientSetInCluster(name, prometheusURL string) (*ClientSet, error) {
}

func createClientSetFromConfig(name, content, prometheusURL string) (*ClientSet, error) {
return createClientSetFromConfigWithSecretRef(name, content, prometheusURL, "")
}

func createClientSetFromConfigWithSecretRef(name, content, prometheusURL, secretRef string) (*ClientSet, error) {
restConfig, err := clientcmd.RESTConfigFromKubeConfig([]byte(content))
if err != nil {
klog.Warningf("Failed to create REST config for cluster %s: %v", name, err)
Expand All @@ -55,6 +63,7 @@ func createClientSetFromConfig(name, content, prometheusURL string) (*ClientSet,
return nil, err
}
cs.config = content
cs.secretRef = secretRef

return cs, nil
}
Expand Down Expand Up @@ -144,6 +153,46 @@ func createK8sProxyTransport(k8sConfig *rest.Config, prometheusURL string) (*k8s
return transportWrapper, nil
}

// readKubeconfigFromSecret reads a kubeconfig from a Kubernetes secret.
// It uses the in-cluster config to access the secret.
func readKubeconfigFromSecret(secretName, secretNamespace, secretKey string) (string, error) {
if secretName == "" || secretNamespace == "" || secretKey == "" {
return "", fmt.Errorf("secret name, namespace and key are required")
}

klog.V(4).Infof("Attempting to read secret %s/%s key %s", secretNamespace, secretName, secretKey)

config, err := rest.InClusterConfig()
if err != nil {
return "", fmt.Errorf("failed to get in-cluster config: %w", err)
}

clientset, err := kubernetes.NewForConfig(config)
if err != nil {
return "", fmt.Errorf("failed to create kubernetes client: %w", err)
}

ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()

secret, err := clientset.CoreV1().Secrets(secretNamespace).Get(ctx, secretName, metav1.GetOptions{})
if err != nil {
return "", fmt.Errorf("failed to get secret %s/%s: %w", secretNamespace, secretName, err)
}

kubeconfigData, ok := secret.Data[secretKey]
if !ok {
return "", fmt.Errorf("key %s not found in secret %s/%s", secretKey, secretNamespace, secretName)
}

if len(kubeconfigData) == 0 {
return "", fmt.Errorf("kubeconfig data is empty in secret %s/%s key %s", secretNamespace, secretName, secretKey)
}

klog.V(4).Infof("Successfully read %d bytes from secret %s/%s key %s", len(kubeconfigData), secretNamespace, secretName, secretKey)
return string(kubeconfigData), nil
}

type k8sProxyTransport struct {
transport http.RoundTripper
apiServerURL string
Expand Down Expand Up @@ -295,8 +344,29 @@ func shouldUpdateCluster(cs *ClientSet, cluster *model.Cluster) bool {
return true
}

// kubeconfig change
if cs.config != string(cluster.Config) {
// Check if secret reference changed
currentSecretRef := ""
if cluster.SecretName != "" && cluster.SecretNamespace != "" && cluster.SecretKey != "" {
currentSecretRef = fmt.Sprintf("%s/%s:%s", cluster.SecretNamespace, cluster.SecretName, cluster.SecretKey)
}
if cs.secretRef != currentSecretRef {
klog.Infof("Secret reference changed for cluster %s, updating from %s to %s", cluster.Name, cs.secretRef, currentSecretRef)
return true
}

// If using secret reference, check if the secret content changed
if currentSecretRef != "" {
kubeconfigContent, err := readKubeconfigFromSecret(cluster.SecretName, cluster.SecretNamespace, cluster.SecretKey)
if err != nil {
klog.Warningf("Failed to read kubeconfig from secret for cluster %s: %v", cluster.Name, err)
} else if cs.config != kubeconfigContent {
klog.Infof("Secret content changed for cluster %s, updating", cluster.Name)
return true
}
}

// kubeconfig change (for static config)
if currentSecretRef == "" && cs.config != string(cluster.Config) {
klog.Infof("Kubeconfig changed for cluster %s, updating", cluster.Name)
return true
}
Expand Down Expand Up @@ -325,6 +395,20 @@ func buildClientSet(cluster *model.Cluster) (*ClientSet, error) {
if cluster.InCluster {
return createClientSetInCluster(cluster.Name, cluster.PrometheusURL)
}

// If SecretRef is configured, read kubeconfig from the secret
if cluster.SecretName != "" && cluster.SecretNamespace != "" && cluster.SecretKey != "" {
secretRef := fmt.Sprintf("%s/%s:%s", cluster.SecretNamespace, cluster.SecretName, cluster.SecretKey)
klog.Infof("Reading kubeconfig for cluster %s from secret %s", cluster.Name, secretRef)

kubeconfigContent, err := readKubeconfigFromSecret(cluster.SecretName, cluster.SecretNamespace, cluster.SecretKey)
if err != nil {
return nil, fmt.Errorf("failed to read kubeconfig from secret: %w", err)
}

return createClientSetFromConfigWithSecretRef(cluster.Name, kubeconfigContent, cluster.PrometheusURL, secretRef)
}

return createClientSetFromConfig(cluster.Name, string(cluster.Config), cluster.PrometheusURL)
}

Expand Down
18 changes: 11 additions & 7 deletions pkg/model/cluster.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,17 @@ package model

type Cluster struct {
Model
Name string `json:"name" gorm:"type:varchar(100);uniqueIndex;not null"`
Description string `json:"description" gorm:"type:text"`
Config SecretString `json:"config" gorm:"type:text"`
PrometheusURL string `json:"prometheus_url,omitempty" gorm:"type:varchar(255)"`
InCluster bool `json:"in_cluster" gorm:"type:boolean;default:false"`
IsDefault bool `json:"is_default" gorm:"type:boolean;default:false"`
Enable bool `json:"enable" gorm:"type:boolean;default:true"`
Name string `json:"name" gorm:"type:varchar(100);uniqueIndex;not null"`
Description string `json:"description" gorm:"type:text"`
Config SecretString `json:"config" gorm:"type:text"`
PrometheusURL string `json:"prometheus_url,omitempty" gorm:"type:varchar(255)"`
InCluster bool `json:"in_cluster" gorm:"type:boolean;default:false"`
IsDefault bool `json:"is_default" gorm:"type:boolean;default:false"`
Enable bool `json:"enable" gorm:"type:boolean;default:true"`
// SecretRef allows reading kubeconfig from a Kubernetes secret
SecretName string `json:"secret_name,omitempty" gorm:"type:varchar(255)"`
SecretNamespace string `json:"secret_namespace,omitempty" gorm:"type:varchar(255)"`
SecretKey string `json:"secret_key,omitempty" gorm:"type:varchar(255)"`
}

func AddCluster(cluster *Cluster) error {
Expand Down
Loading