WIP - before the testing.

This commit is contained in:
2025-11-23 15:24:51 +00:00
parent 3fee780dea
commit f50f0a9b49
21 changed files with 4582 additions and 13 deletions
+207
View File
@@ -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
}
+224
View File
@@ -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")
}
+249
View File
@@ -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
}
+256
View File
@@ -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
}