mirror of
https://github.com/lukaszraczylo/kportal.git
synced 2026-07-08 06:35:10 +00:00
WIP - before the testing.
This commit is contained in:
@@ -0,0 +1,207 @@
|
||||
package k8s
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
"k8s.io/client-go/kubernetes"
|
||||
"k8s.io/client-go/rest"
|
||||
"k8s.io/client-go/tools/clientcmd"
|
||||
)
|
||||
|
||||
// ClientPool manages Kubernetes clients per context with thread-safe access.
|
||||
type ClientPool struct {
|
||||
mu sync.RWMutex
|
||||
clients map[string]*kubernetes.Clientset
|
||||
configs map[string]*rest.Config
|
||||
loader clientcmd.ClientConfig
|
||||
}
|
||||
|
||||
// NewClientPool creates a new ClientPool instance.
|
||||
func NewClientPool() (*ClientPool, error) {
|
||||
// Load kubeconfig using default loading rules
|
||||
loadingRules := clientcmd.NewDefaultClientConfigLoadingRules()
|
||||
configOverrides := &clientcmd.ConfigOverrides{}
|
||||
|
||||
loader := clientcmd.NewNonInteractiveDeferredLoadingClientConfig(loadingRules, configOverrides)
|
||||
|
||||
return &ClientPool{
|
||||
clients: make(map[string]*kubernetes.Clientset),
|
||||
configs: make(map[string]*rest.Config),
|
||||
loader: loader,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetClient returns a Kubernetes client for the given context.
|
||||
// Clients are cached and reused across multiple calls.
|
||||
// This method is thread-safe.
|
||||
func (p *ClientPool) GetClient(contextName string) (*kubernetes.Clientset, error) {
|
||||
// Try to get cached client (read lock)
|
||||
p.mu.RLock()
|
||||
client, exists := p.clients[contextName]
|
||||
p.mu.RUnlock()
|
||||
|
||||
if exists {
|
||||
return client, nil
|
||||
}
|
||||
|
||||
// Client doesn't exist, create it (write lock)
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
// Double-check in case another goroutine created it while we waited
|
||||
if client, exists := p.clients[contextName]; exists {
|
||||
return client, nil
|
||||
}
|
||||
|
||||
// Create new client
|
||||
config, err := p.getRestConfig(contextName)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get rest config for context %s: %w", contextName, err)
|
||||
}
|
||||
|
||||
client, err = kubernetes.NewForConfig(config)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create client for context %s: %w", contextName, err)
|
||||
}
|
||||
|
||||
// Cache the client and config
|
||||
p.clients[contextName] = client
|
||||
p.configs[contextName] = config
|
||||
|
||||
return client, nil
|
||||
}
|
||||
|
||||
// GetRestConfig returns the REST config for the given context.
|
||||
// Configs are cached and reused.
|
||||
// This method is thread-safe.
|
||||
func (p *ClientPool) GetRestConfig(contextName string) (*rest.Config, error) {
|
||||
// Try to get cached config (read lock)
|
||||
p.mu.RLock()
|
||||
config, exists := p.configs[contextName]
|
||||
p.mu.RUnlock()
|
||||
|
||||
if exists {
|
||||
return config, nil
|
||||
}
|
||||
|
||||
// Config doesn't exist, create it (write lock)
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
// Double-check in case another goroutine created it while we waited
|
||||
if config, exists := p.configs[contextName]; exists {
|
||||
return config, nil
|
||||
}
|
||||
|
||||
// Create new config
|
||||
config, err := p.getRestConfig(contextName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Cache the config
|
||||
p.configs[contextName] = config
|
||||
|
||||
return config, nil
|
||||
}
|
||||
|
||||
// getRestConfig creates a REST config for the given context.
|
||||
// This is an internal method that should only be called with a lock held.
|
||||
func (p *ClientPool) getRestConfig(contextName string) (*rest.Config, error) {
|
||||
// Load the raw kubeconfig
|
||||
rawConfig, err := p.loader.RawConfig()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to load kubeconfig: %w", err)
|
||||
}
|
||||
|
||||
// Check if the context exists
|
||||
if _, exists := rawConfig.Contexts[contextName]; !exists {
|
||||
return nil, fmt.Errorf("context %s not found in kubeconfig", contextName)
|
||||
}
|
||||
|
||||
// Create config overrides for the specific context
|
||||
overrides := &clientcmd.ConfigOverrides{
|
||||
CurrentContext: contextName,
|
||||
}
|
||||
|
||||
// Build the config
|
||||
config, err := clientcmd.NewNonInteractiveClientConfig(
|
||||
rawConfig,
|
||||
contextName,
|
||||
overrides,
|
||||
clientcmd.NewDefaultClientConfigLoadingRules(),
|
||||
).ClientConfig()
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to build client config for context %s: %w", contextName, err)
|
||||
}
|
||||
|
||||
return config, nil
|
||||
}
|
||||
|
||||
// GetCurrentContext returns the name of the current context from kubeconfig.
|
||||
func (p *ClientPool) GetCurrentContext() (string, error) {
|
||||
rawConfig, err := p.loader.RawConfig()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to load kubeconfig: %w", err)
|
||||
}
|
||||
|
||||
return rawConfig.CurrentContext, nil
|
||||
}
|
||||
|
||||
// ListContexts returns a list of all available contexts from kubeconfig.
|
||||
func (p *ClientPool) ListContexts() ([]string, error) {
|
||||
rawConfig, err := p.loader.RawConfig()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to load kubeconfig: %w", err)
|
||||
}
|
||||
|
||||
contexts := make([]string, 0, len(rawConfig.Contexts))
|
||||
for name := range rawConfig.Contexts {
|
||||
contexts = append(contexts, name)
|
||||
}
|
||||
|
||||
return contexts, nil
|
||||
}
|
||||
|
||||
// ClearCache removes all cached clients and configs.
|
||||
// This is useful for testing or when kubeconfig has been updated.
|
||||
func (p *ClientPool) ClearCache() {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
p.clients = make(map[string]*kubernetes.Clientset)
|
||||
p.configs = make(map[string]*rest.Config)
|
||||
}
|
||||
|
||||
// RemoveContext removes a specific context from the cache.
|
||||
// This is useful when a context is removed or updated.
|
||||
func (p *ClientPool) RemoveContext(contextName string) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
delete(p.clients, contextName)
|
||||
delete(p.configs, contextName)
|
||||
}
|
||||
|
||||
// GetNamespace returns the default namespace for the given context.
|
||||
func (p *ClientPool) GetNamespace(contextName string) (string, error) {
|
||||
rawConfig, err := p.loader.RawConfig()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to load kubeconfig: %w", err)
|
||||
}
|
||||
|
||||
context, exists := rawConfig.Contexts[contextName]
|
||||
if !exists {
|
||||
return "", fmt.Errorf("context %s not found", contextName)
|
||||
}
|
||||
|
||||
// Return the namespace from the context, or "default" if not specified
|
||||
if context.Namespace == "" {
|
||||
return corev1.NamespaceDefault, nil
|
||||
}
|
||||
|
||||
return context.Namespace, nil
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
package k8s
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestNewClientPool(t *testing.T) {
|
||||
pool, err := NewClientPool()
|
||||
|
||||
assert.NoError(t, err, "NewClientPool should not return error")
|
||||
assert.NotNil(t, pool, "pool should not be nil")
|
||||
assert.NotNil(t, pool.clients, "clients map should be initialized")
|
||||
assert.NotNil(t, pool.configs, "configs map should be initialized")
|
||||
assert.Empty(t, pool.clients, "clients map should be empty initially")
|
||||
assert.Empty(t, pool.configs, "configs map should be empty initially")
|
||||
}
|
||||
|
||||
func TestClientPool_ClearCache(t *testing.T) {
|
||||
pool, err := NewClientPool()
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Initially empty
|
||||
assert.Empty(t, pool.clients)
|
||||
assert.Empty(t, pool.configs)
|
||||
|
||||
// Call ClearCache on empty pool (should not panic)
|
||||
pool.ClearCache()
|
||||
|
||||
// Should still be empty
|
||||
assert.Empty(t, pool.clients)
|
||||
assert.Empty(t, pool.configs)
|
||||
}
|
||||
|
||||
func TestClientPool_RemoveContext(t *testing.T) {
|
||||
pool, err := NewClientPool()
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Remove from empty pool (should not panic)
|
||||
pool.RemoveContext("non-existent-context")
|
||||
|
||||
// Should still be empty
|
||||
assert.Empty(t, pool.clients)
|
||||
assert.Empty(t, pool.configs)
|
||||
}
|
||||
|
||||
func TestClientPool_Structure(t *testing.T) {
|
||||
pool, err := NewClientPool()
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Verify structure - just check maps are initialized
|
||||
assert.NotNil(t, pool.clients, "clients map should exist")
|
||||
assert.NotNil(t, pool.configs, "configs map should exist")
|
||||
assert.NotNil(t, pool.loader, "loader should exist")
|
||||
}
|
||||
|
||||
func TestClientPool_GetCurrentContext(t *testing.T) {
|
||||
pool, err := NewClientPool()
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Try to get current context
|
||||
// This may fail if kubeconfig is not available, which is fine for unit tests
|
||||
context, err := pool.GetCurrentContext()
|
||||
|
||||
if err == nil {
|
||||
// If successful, context should be a string
|
||||
assert.IsType(t, "", context)
|
||||
} else {
|
||||
// If failed, error should mention kubeconfig
|
||||
assert.Contains(t, err.Error(), "kubeconfig")
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientPool_ListContexts(t *testing.T) {
|
||||
pool, err := NewClientPool()
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Try to list contexts
|
||||
// This may fail if kubeconfig is not available, which is fine for unit tests
|
||||
contexts, err := pool.ListContexts()
|
||||
|
||||
if err == nil {
|
||||
// If successful, contexts should be a slice
|
||||
assert.NotNil(t, contexts)
|
||||
assert.IsType(t, []string{}, contexts)
|
||||
} else {
|
||||
// If failed, error should mention kubeconfig
|
||||
assert.Contains(t, err.Error(), "kubeconfig")
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientPool_GetNamespace(t *testing.T) {
|
||||
pool, err := NewClientPool()
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Try to get namespace for a non-existent context
|
||||
namespace, err := pool.GetNamespace("non-existent-context")
|
||||
|
||||
// This should fail with context not found error
|
||||
if err != nil {
|
||||
// Error is expected, check it mentions the context or kubeconfig
|
||||
errMsg := err.Error()
|
||||
containsContext := assert.Contains(t, errMsg, "context", "error should mention context") ||
|
||||
assert.Contains(t, errMsg, "kubeconfig", "error should mention kubeconfig")
|
||||
assert.True(t, containsContext)
|
||||
} else {
|
||||
// If no error (unlikely), namespace should be a string
|
||||
assert.IsType(t, "", namespace)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientPool_GetClient_NonExistentContext(t *testing.T) {
|
||||
pool, err := NewClientPool()
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Try to get a client for a non-existent context
|
||||
client, err := pool.GetClient("non-existent-context")
|
||||
|
||||
// This should fail
|
||||
assert.Error(t, err, "should return error for non-existent context")
|
||||
assert.Nil(t, client, "client should be nil on error")
|
||||
}
|
||||
|
||||
func TestClientPool_GetRestConfig_NonExistentContext(t *testing.T) {
|
||||
pool, err := NewClientPool()
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Try to get a rest config for a non-existent context
|
||||
config, err := pool.GetRestConfig("non-existent-context")
|
||||
|
||||
// This should fail
|
||||
assert.Error(t, err, "should return error for non-existent context")
|
||||
assert.Nil(t, config, "config should be nil on error")
|
||||
}
|
||||
|
||||
func TestClientPool_ThreadSafety(t *testing.T) {
|
||||
pool, err := NewClientPool()
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Test that concurrent operations don't panic
|
||||
// We don't check for errors as the context may not exist
|
||||
done := make(chan bool)
|
||||
|
||||
for i := 0; i < 10; i++ {
|
||||
go func() {
|
||||
pool.ClearCache()
|
||||
pool.RemoveContext("test-context")
|
||||
pool.GetCurrentContext()
|
||||
pool.ListContexts()
|
||||
done <- true
|
||||
}()
|
||||
}
|
||||
|
||||
// Wait for all goroutines to complete
|
||||
for i := 0; i < 10; i++ {
|
||||
<-done
|
||||
}
|
||||
|
||||
// If we get here without panic, thread safety is working
|
||||
assert.True(t, true, "concurrent operations should not panic")
|
||||
}
|
||||
|
||||
func TestClientPool_CacheBehavior(t *testing.T) {
|
||||
pool, err := NewClientPool()
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Initially empty
|
||||
assert.Empty(t, pool.clients)
|
||||
assert.Empty(t, pool.configs)
|
||||
|
||||
// Add a context to the internal cache manually for testing
|
||||
// Note: This is a simplified test that doesn't require actual kubeconfig
|
||||
pool.mu.Lock()
|
||||
testContext := "test-context"
|
||||
pool.clients[testContext] = nil // Just mark as cached
|
||||
pool.configs[testContext] = nil // Just mark as cached
|
||||
pool.mu.Unlock()
|
||||
|
||||
// Verify it's cached
|
||||
pool.mu.RLock()
|
||||
_, clientExists := pool.clients[testContext]
|
||||
_, configExists := pool.configs[testContext]
|
||||
pool.mu.RUnlock()
|
||||
|
||||
assert.True(t, clientExists, "client should be in cache")
|
||||
assert.True(t, configExists, "config should be in cache")
|
||||
|
||||
// Remove context
|
||||
pool.RemoveContext(testContext)
|
||||
|
||||
// Verify it's removed
|
||||
pool.mu.RLock()
|
||||
_, clientExists = pool.clients[testContext]
|
||||
_, configExists = pool.configs[testContext]
|
||||
pool.mu.RUnlock()
|
||||
|
||||
assert.False(t, clientExists, "client should be removed from cache")
|
||||
assert.False(t, configExists, "config should be removed from cache")
|
||||
|
||||
// Add back and clear cache
|
||||
pool.mu.Lock()
|
||||
pool.clients[testContext] = nil
|
||||
pool.configs[testContext] = nil
|
||||
pool.mu.Unlock()
|
||||
|
||||
pool.ClearCache()
|
||||
|
||||
// Verify cache is cleared
|
||||
assert.Empty(t, pool.clients, "clients should be cleared")
|
||||
assert.Empty(t, pool.configs, "configs should be cleared")
|
||||
}
|
||||
|
||||
func TestClientPool_EmptyPoolOperations(t *testing.T) {
|
||||
pool, err := NewClientPool()
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Test various operations on empty pool (should not panic)
|
||||
pool.ClearCache()
|
||||
pool.RemoveContext("any-context")
|
||||
|
||||
// All these operations should complete without panic
|
||||
assert.NotNil(t, pool, "pool should still be valid")
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
package k8s
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/client-go/rest"
|
||||
"k8s.io/client-go/tools/portforward"
|
||||
"k8s.io/client-go/transport/spdy"
|
||||
)
|
||||
|
||||
// PortForwarder handles Kubernetes port-forwarding operations.
|
||||
type PortForwarder struct {
|
||||
clientPool *ClientPool
|
||||
resolver *ResourceResolver
|
||||
}
|
||||
|
||||
// NewPortForwarder creates a new PortForwarder instance.
|
||||
func NewPortForwarder(clientPool *ClientPool, resolver *ResourceResolver) *PortForwarder {
|
||||
return &PortForwarder{
|
||||
clientPool: clientPool,
|
||||
resolver: resolver,
|
||||
}
|
||||
}
|
||||
|
||||
// ForwardRequest contains the parameters for a port-forward request.
|
||||
type ForwardRequest struct {
|
||||
ContextName string // Kubernetes context name
|
||||
Namespace string // Namespace
|
||||
Resource string // Resource (pod/name or service/name)
|
||||
Selector string // Label selector (for pod resolution)
|
||||
LocalPort int // Local port
|
||||
RemotePort int // Remote port
|
||||
StopChan chan struct{}
|
||||
ReadyChan chan struct{}
|
||||
Out io.Writer // Output writer for logs
|
||||
ErrOut io.Writer // Error output writer
|
||||
}
|
||||
|
||||
// Forward establishes a port-forward connection to a Kubernetes resource.
|
||||
// It supports both pod and service forwarding.
|
||||
// The connection runs until StopChan is closed or an error occurs.
|
||||
func (pf *PortForwarder) Forward(ctx context.Context, req *ForwardRequest) error {
|
||||
// Resolve the resource to an actual pod name
|
||||
resolvedResource, err := pf.resolver.Resolve(ctx, req.ContextName, req.Namespace, req.Resource, req.Selector)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to resolve resource: %w", err)
|
||||
}
|
||||
|
||||
// Parse the resolved resource
|
||||
parts := strings.SplitN(resolvedResource, "/", 2)
|
||||
if len(parts) != 2 {
|
||||
return fmt.Errorf("invalid resolved resource format: %s", resolvedResource)
|
||||
}
|
||||
|
||||
resourceType := parts[0]
|
||||
resourceName := parts[1]
|
||||
|
||||
// Handle different resource types
|
||||
switch resourceType {
|
||||
case "pod":
|
||||
return pf.forwardToPod(ctx, req, resourceName)
|
||||
case "service":
|
||||
return pf.forwardToService(ctx, req, resourceName)
|
||||
default:
|
||||
return fmt.Errorf("unsupported resource type: %s", resourceType)
|
||||
}
|
||||
}
|
||||
|
||||
// forwardToPod establishes a port-forward to a specific pod.
|
||||
func (pf *PortForwarder) forwardToPod(ctx context.Context, req *ForwardRequest, podName string) error {
|
||||
// Get Kubernetes client and config
|
||||
client, err := pf.clientPool.GetClient(req.ContextName)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get client: %w", err)
|
||||
}
|
||||
|
||||
config, err := pf.clientPool.GetRestConfig(req.ContextName)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get rest config: %w", err)
|
||||
}
|
||||
|
||||
// Verify pod exists and is running
|
||||
pod, err := client.CoreV1().Pods(req.Namespace).Get(ctx, podName, metav1.GetOptions{})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get pod: %w", err)
|
||||
}
|
||||
|
||||
if pod.Status.Phase != corev1.PodRunning {
|
||||
return fmt.Errorf("pod is not running (current phase: %s)", pod.Status.Phase)
|
||||
}
|
||||
|
||||
// Build the port-forward URL
|
||||
reqURL := client.CoreV1().RESTClient().Post().
|
||||
Resource("pods").
|
||||
Namespace(req.Namespace).
|
||||
Name(podName).
|
||||
SubResource("portforward").
|
||||
URL()
|
||||
|
||||
// Create the port-forward
|
||||
return pf.executePortForward(config, reqURL, req)
|
||||
}
|
||||
|
||||
// forwardToService establishes a port-forward to a service.
|
||||
// This resolves the service to its backing pods and forwards to one of them.
|
||||
func (pf *PortForwarder) forwardToService(ctx context.Context, req *ForwardRequest, serviceName string) error {
|
||||
// Get Kubernetes client
|
||||
client, err := pf.clientPool.GetClient(req.ContextName)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get client: %w", err)
|
||||
}
|
||||
|
||||
// Get the service
|
||||
service, err := client.CoreV1().Services(req.Namespace).Get(ctx, serviceName, metav1.GetOptions{})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get service: %w", err)
|
||||
}
|
||||
|
||||
// Get pods backing the service using label selector
|
||||
selector := metav1.FormatLabelSelector(&metav1.LabelSelector{MatchLabels: service.Spec.Selector})
|
||||
pods, err := client.CoreV1().Pods(req.Namespace).List(ctx, metav1.ListOptions{
|
||||
LabelSelector: selector,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to list pods for service: %w", err)
|
||||
}
|
||||
|
||||
// Find first running pod
|
||||
var targetPod *corev1.Pod
|
||||
for i := range pods.Items {
|
||||
pod := &pods.Items[i]
|
||||
if pod.Status.Phase == corev1.PodRunning {
|
||||
targetPod = pod
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if targetPod == nil {
|
||||
return fmt.Errorf("no running pods found for service %s", serviceName)
|
||||
}
|
||||
|
||||
// Forward to the pod
|
||||
config, err := pf.clientPool.GetRestConfig(req.ContextName)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get rest config: %w", err)
|
||||
}
|
||||
|
||||
reqURL := client.CoreV1().RESTClient().Post().
|
||||
Resource("pods").
|
||||
Namespace(req.Namespace).
|
||||
Name(targetPod.Name).
|
||||
SubResource("portforward").
|
||||
URL()
|
||||
|
||||
return pf.executePortForward(config, reqURL, req)
|
||||
}
|
||||
|
||||
// executePortForward performs the actual port-forward operation.
|
||||
func (pf *PortForwarder) executePortForward(config *rest.Config, url *url.URL, req *ForwardRequest) error {
|
||||
// Create SPDY roundtripper
|
||||
transport, upgrader, err := spdy.RoundTripperFor(config)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create round tripper: %w", err)
|
||||
}
|
||||
|
||||
// Create dialer
|
||||
dialer := spdy.NewDialer(upgrader, &http.Client{Transport: transport}, http.MethodPost, url)
|
||||
|
||||
// Set up port forwarding
|
||||
ports := []string{fmt.Sprintf("%d:%d", req.LocalPort, req.RemotePort)}
|
||||
|
||||
// Create output writers
|
||||
out := req.Out
|
||||
errOut := req.ErrOut
|
||||
if out == nil {
|
||||
out = io.Discard
|
||||
}
|
||||
if errOut == nil {
|
||||
errOut = io.Discard
|
||||
}
|
||||
|
||||
// Create port forwarder
|
||||
fw, err := portforward.New(dialer, ports, req.StopChan, req.ReadyChan, out, errOut)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create port forwarder: %w", err)
|
||||
}
|
||||
|
||||
// Start forwarding (blocks until stopped or error)
|
||||
if err := fw.ForwardPorts(); err != nil {
|
||||
return fmt.Errorf("port forward failed: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetPodForResource returns the pod name that would be used for forwarding.
|
||||
// This is useful for logging and debugging.
|
||||
func (pf *PortForwarder) GetPodForResource(ctx context.Context, contextName, namespace, resource, selector string) (string, error) {
|
||||
resolvedResource, err := pf.resolver.Resolve(ctx, contextName, namespace, resource, selector)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
parts := strings.SplitN(resolvedResource, "/", 2)
|
||||
if len(parts) != 2 {
|
||||
return "", fmt.Errorf("invalid resolved resource format: %s", resolvedResource)
|
||||
}
|
||||
|
||||
resourceType := parts[0]
|
||||
resourceName := parts[1]
|
||||
|
||||
if resourceType == "service" {
|
||||
// For services, need to resolve to backing pod
|
||||
client, err := pf.clientPool.GetClient(contextName)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to get client: %w", err)
|
||||
}
|
||||
|
||||
service, err := client.CoreV1().Services(namespace).Get(ctx, resourceName, metav1.GetOptions{})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to get service: %w", err)
|
||||
}
|
||||
|
||||
selector := metav1.FormatLabelSelector(&metav1.LabelSelector{MatchLabels: service.Spec.Selector})
|
||||
pods, err := client.CoreV1().Pods(namespace).List(ctx, metav1.ListOptions{
|
||||
LabelSelector: selector,
|
||||
})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to list pods: %w", err)
|
||||
}
|
||||
|
||||
for i := range pods.Items {
|
||||
if pods.Items[i].Status.Phase == corev1.PodRunning {
|
||||
return pods.Items[i].Name, nil
|
||||
}
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("no running pods found for service")
|
||||
}
|
||||
|
||||
return resourceName, nil
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
package k8s
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
)
|
||||
|
||||
const (
|
||||
// Default cache TTL for resolved resources
|
||||
defaultCacheTTL = 30 * time.Second
|
||||
)
|
||||
|
||||
// ResolvedResource represents a resolved Kubernetes resource.
|
||||
type ResolvedResource struct {
|
||||
Name string // The resolved pod or service name
|
||||
Namespace string // The namespace
|
||||
Timestamp time.Time // When this was resolved
|
||||
}
|
||||
|
||||
// cacheEntry stores a cached resolution result with expiry.
|
||||
type cacheEntry struct {
|
||||
resource ResolvedResource
|
||||
expiresAt time.Time
|
||||
}
|
||||
|
||||
// ResourceResolver resolves Kubernetes resources with caching.
|
||||
// It handles prefix matching for pods and label selector resolution.
|
||||
type ResourceResolver struct {
|
||||
clientPool *ClientPool
|
||||
cache map[string]cacheEntry // key: contextName/namespace/resource -> resolved name
|
||||
cacheMu sync.RWMutex
|
||||
cacheTTL time.Duration
|
||||
}
|
||||
|
||||
// NewResourceResolver creates a new ResourceResolver instance.
|
||||
func NewResourceResolver(clientPool *ClientPool) *ResourceResolver {
|
||||
return &ResourceResolver{
|
||||
clientPool: clientPool,
|
||||
cache: make(map[string]cacheEntry),
|
||||
cacheTTL: defaultCacheTTL,
|
||||
}
|
||||
}
|
||||
|
||||
// SetCacheTTL sets the cache TTL for resolved resources.
|
||||
func (r *ResourceResolver) SetCacheTTL(ttl time.Duration) {
|
||||
r.cacheTTL = ttl
|
||||
}
|
||||
|
||||
// Resolve resolves a resource name to an actual pod or service name.
|
||||
// It supports:
|
||||
// - pod/prefix: Prefix matching (e.g., "pod/my-app" matches "my-app-xyz789")
|
||||
// - pod + selector: Label selector matching (e.g., "pod" with selector "app=nginx")
|
||||
// - service/name: Direct service name (no resolution needed)
|
||||
func (r *ResourceResolver) Resolve(ctx context.Context, contextName, namespace, resource, selector string) (string, error) {
|
||||
// Parse resource type and name
|
||||
parts := strings.SplitN(resource, "/", 2)
|
||||
resourceType := parts[0]
|
||||
|
||||
// Services don't need resolution
|
||||
if resourceType == "service" {
|
||||
if len(parts) < 2 {
|
||||
return "", fmt.Errorf("invalid service resource format: %s", resource)
|
||||
}
|
||||
return resource, nil
|
||||
}
|
||||
|
||||
// Handle pod resolution
|
||||
if resourceType == "pod" {
|
||||
if len(parts) == 2 {
|
||||
// pod/prefix format - prefix matching
|
||||
prefix := parts[1]
|
||||
return r.resolvePodPrefix(ctx, contextName, namespace, prefix)
|
||||
}
|
||||
|
||||
// pod with selector - label selector matching
|
||||
if selector != "" {
|
||||
return r.resolvePodSelector(ctx, contextName, namespace, selector)
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("pod resource requires either a name prefix (pod/name) or a selector")
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("unsupported resource type: %s", resourceType)
|
||||
}
|
||||
|
||||
// resolvePodPrefix resolves a pod name using prefix matching.
|
||||
// It returns the newest running pod that matches the prefix.
|
||||
func (r *ResourceResolver) resolvePodPrefix(ctx context.Context, contextName, namespace, prefix string) (string, error) {
|
||||
// Check cache first
|
||||
cacheKey := fmt.Sprintf("%s/%s/pod/%s", contextName, namespace, prefix)
|
||||
if cached := r.getFromCache(cacheKey); cached != "" {
|
||||
return fmt.Sprintf("pod/%s", cached), nil
|
||||
}
|
||||
|
||||
// Get Kubernetes client
|
||||
client, err := r.clientPool.GetClient(contextName)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to get client: %w", err)
|
||||
}
|
||||
|
||||
// List all pods in the namespace
|
||||
pods, err := client.CoreV1().Pods(namespace).List(ctx, metav1.ListOptions{})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to list pods: %w", err)
|
||||
}
|
||||
|
||||
// Find pods matching the prefix
|
||||
var matchingPods []*corev1.Pod
|
||||
for i := range pods.Items {
|
||||
pod := &pods.Items[i]
|
||||
if strings.HasPrefix(pod.Name, prefix) && pod.Status.Phase == corev1.PodRunning {
|
||||
matchingPods = append(matchingPods, pod)
|
||||
}
|
||||
}
|
||||
|
||||
if len(matchingPods) == 0 {
|
||||
return "", fmt.Errorf("no running pods found matching prefix '%s' in namespace %s", prefix, namespace)
|
||||
}
|
||||
|
||||
// Sort by creation timestamp (newest first)
|
||||
sort.Slice(matchingPods, func(i, j int) bool {
|
||||
return matchingPods[i].CreationTimestamp.After(matchingPods[j].CreationTimestamp.Time)
|
||||
})
|
||||
|
||||
// Return the newest pod
|
||||
resolvedName := matchingPods[0].Name
|
||||
r.putInCache(cacheKey, resolvedName)
|
||||
|
||||
return fmt.Sprintf("pod/%s", resolvedName), nil
|
||||
}
|
||||
|
||||
// resolvePodSelector resolves a pod name using label selectors.
|
||||
// It returns the first running pod matching the selector.
|
||||
func (r *ResourceResolver) resolvePodSelector(ctx context.Context, contextName, namespace, selector string) (string, error) {
|
||||
// Check cache first
|
||||
cacheKey := fmt.Sprintf("%s/%s/pod?selector=%s", contextName, namespace, selector)
|
||||
if cached := r.getFromCache(cacheKey); cached != "" {
|
||||
return fmt.Sprintf("pod/%s", cached), nil
|
||||
}
|
||||
|
||||
// Get Kubernetes client
|
||||
client, err := r.clientPool.GetClient(contextName)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to get client: %w", err)
|
||||
}
|
||||
|
||||
// List pods matching the selector
|
||||
pods, err := client.CoreV1().Pods(namespace).List(ctx, metav1.ListOptions{
|
||||
LabelSelector: selector,
|
||||
})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to list pods with selector '%s': %w", selector, err)
|
||||
}
|
||||
|
||||
// Find first running pod
|
||||
for i := range pods.Items {
|
||||
pod := &pods.Items[i]
|
||||
if pod.Status.Phase == corev1.PodRunning {
|
||||
resolvedName := pod.Name
|
||||
r.putInCache(cacheKey, resolvedName)
|
||||
return fmt.Sprintf("pod/%s", resolvedName), nil
|
||||
}
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("no running pods found matching selector '%s' in namespace %s", selector, namespace)
|
||||
}
|
||||
|
||||
// getFromCache retrieves a cached resolution result if it exists and hasn't expired.
|
||||
func (r *ResourceResolver) getFromCache(key string) string {
|
||||
r.cacheMu.RLock()
|
||||
defer r.cacheMu.RUnlock()
|
||||
|
||||
entry, exists := r.cache[key]
|
||||
if !exists {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Check if expired
|
||||
if time.Now().After(entry.expiresAt) {
|
||||
return ""
|
||||
}
|
||||
|
||||
return entry.resource.Name
|
||||
}
|
||||
|
||||
// putInCache stores a resolution result in the cache with TTL.
|
||||
func (r *ResourceResolver) putInCache(key, value string) {
|
||||
r.cacheMu.Lock()
|
||||
defer r.cacheMu.Unlock()
|
||||
|
||||
r.cache[key] = cacheEntry{
|
||||
resource: ResolvedResource{
|
||||
Name: value,
|
||||
Timestamp: time.Now(),
|
||||
},
|
||||
expiresAt: time.Now().Add(r.cacheTTL),
|
||||
}
|
||||
}
|
||||
|
||||
// ClearCache clears all cached resolution results.
|
||||
func (r *ResourceResolver) ClearCache() {
|
||||
r.cacheMu.Lock()
|
||||
defer r.cacheMu.Unlock()
|
||||
|
||||
r.cache = make(map[string]cacheEntry)
|
||||
}
|
||||
|
||||
// InvalidateCache invalidates cache entries for a specific resource.
|
||||
func (r *ResourceResolver) InvalidateCache(contextName, namespace, resource string) {
|
||||
r.cacheMu.Lock()
|
||||
defer r.cacheMu.Unlock()
|
||||
|
||||
// Remove exact match
|
||||
delete(r.cache, fmt.Sprintf("%s/%s/%s", contextName, namespace, resource))
|
||||
|
||||
// Remove prefix matches (for selector-based resources)
|
||||
prefix := fmt.Sprintf("%s/%s/", contextName, namespace)
|
||||
for key := range r.cache {
|
||||
if strings.HasPrefix(key, prefix) {
|
||||
delete(r.cache, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// GetPodList returns a list of pods matching the given criteria.
|
||||
// This is useful for debugging and testing.
|
||||
func (r *ResourceResolver) GetPodList(ctx context.Context, contextName, namespace, selector string) ([]*corev1.Pod, error) {
|
||||
client, err := r.clientPool.GetClient(contextName)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get client: %w", err)
|
||||
}
|
||||
|
||||
listOptions := metav1.ListOptions{}
|
||||
if selector != "" {
|
||||
listOptions.LabelSelector = selector
|
||||
}
|
||||
|
||||
pods, err := client.CoreV1().Pods(namespace).List(ctx, listOptions)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list pods: %w", err)
|
||||
}
|
||||
|
||||
result := make([]*corev1.Pod, len(pods.Items))
|
||||
for i := range pods.Items {
|
||||
result[i] = &pods.Items[i]
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
Reference in New Issue
Block a user