mirror of
https://github.com/amir20/dozzle.git
synced 2026-08-07 10:54:44 +00:00
bd4651fb0b
Deploy VitePress site to Pages / build (push) Has been cancelled
Deploy VitePress site to Pages / Deploy (push) Has been cancelled
Push container / Push branches and PRs (push) Has been cancelled
Test / Typecheck (push) Has been cancelled
Test / JavaScript Tests (push) Has been cancelled
Test / Go Tests (push) Has been cancelled
Test / Go Staticcheck (push) Has been cancelled
Test / Integration Tests (push) Has been cancelled
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
789 lines
21 KiB
Go
789 lines
21 KiB
Go
package k8s
|
|
|
|
import (
|
|
"context"
|
|
"encoding/base64"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"maps"
|
|
"regexp"
|
|
"slices"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"os"
|
|
|
|
"github.com/amir20/dozzle/internal/container"
|
|
"github.com/amir20/dozzle/internal/utils"
|
|
corev1 "k8s.io/api/core/v1"
|
|
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
|
"k8s.io/apimachinery/pkg/api/meta"
|
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
|
"k8s.io/apimachinery/pkg/runtime/schema"
|
|
"k8s.io/apimachinery/pkg/watch"
|
|
"k8s.io/client-go/discovery"
|
|
"k8s.io/client-go/discovery/cached/memory"
|
|
"k8s.io/client-go/dynamic"
|
|
|
|
"github.com/rs/zerolog/log"
|
|
|
|
"github.com/samber/lo"
|
|
lop "github.com/samber/lo/parallel"
|
|
|
|
"k8s.io/client-go/kubernetes"
|
|
"k8s.io/client-go/kubernetes/scheme"
|
|
"k8s.io/client-go/rest"
|
|
"k8s.io/client-go/restmapper"
|
|
"k8s.io/client-go/tools/clientcmd"
|
|
"k8s.io/client-go/tools/remotecommand"
|
|
)
|
|
|
|
type K8sClient struct {
|
|
Clientset kubernetes.Interface
|
|
DynamicClient dynamic.Interface
|
|
restMapper meta.RESTMapper
|
|
namespace []string
|
|
config *rest.Config
|
|
host container.Host
|
|
ownerCacheMu sync.Mutex
|
|
ownerCache map[string]ownerLookupResult
|
|
|
|
mapperMu sync.Mutex
|
|
lastMapperReset time.Time
|
|
}
|
|
|
|
func NewK8sClient(namespace []string) (*K8sClient, error) {
|
|
var config *rest.Config
|
|
var err error
|
|
|
|
if len(namespace) == 0 {
|
|
namespace = []string{metav1.NamespaceAll}
|
|
}
|
|
|
|
// Check if we're running in cluster
|
|
if os.Getenv("KUBERNETES_SERVICE_HOST") != "" {
|
|
config, err = rest.InClusterConfig()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
log.Info().Msg("Running in-cluster mode")
|
|
} else {
|
|
kubeconfig := os.Getenv("KUBECONFIG")
|
|
if kubeconfig == "" {
|
|
kubeconfig = os.Getenv("HOME") + "/.kube/config"
|
|
}
|
|
config, err = clientcmd.BuildConfigFromFlags("", kubeconfig)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
log.Info().Msgf("Running in local mode with kubeconfig: %s", kubeconfig)
|
|
|
|
}
|
|
|
|
clientset, err := kubernetes.NewForConfig(config)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
dynamicClient, err := dynamic.NewForConfig(config)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
discoveryClient, err := discovery.NewDiscoveryClientForConfig(config)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
nodes, err := clientset.CoreV1().Nodes().List(context.Background(), metav1.ListOptions{})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if len(nodes.Items) == 0 {
|
|
return nil, fmt.Errorf("nodes not found")
|
|
}
|
|
node := nodes.Items[0]
|
|
|
|
return &K8sClient{
|
|
Clientset: clientset,
|
|
DynamicClient: dynamicClient,
|
|
restMapper: restmapper.NewDeferredDiscoveryRESTMapper(memory.NewMemCacheClient(discoveryClient)),
|
|
namespace: namespace,
|
|
config: config,
|
|
host: container.Host{
|
|
ID: node.Status.NodeInfo.MachineID,
|
|
Name: node.Name,
|
|
},
|
|
ownerCache: make(map[string]ownerLookupResult),
|
|
}, nil
|
|
}
|
|
|
|
type k8sOwner struct {
|
|
APIVersion string
|
|
Kind string
|
|
Namespace string
|
|
Name string
|
|
UID string
|
|
TypeKey string
|
|
Key string
|
|
}
|
|
|
|
type ownerLookupResult struct {
|
|
ownerReferences []metav1.OwnerReference
|
|
found bool
|
|
expiresAt time.Time
|
|
}
|
|
|
|
const (
|
|
// TTL bounds owner-cache staleness so entries recover after RBAC is granted
|
|
// and let expired entries be swept, keeping the cache from growing forever.
|
|
ownerCacheTTL = 5 * time.Minute
|
|
// Hard cap so a cluster with heavy ReplicaSet churn can't grow the cache unbounded.
|
|
ownerCacheMaxSize = 4096
|
|
// Evict down to this size once the cap is hit, so eviction has hysteresis and
|
|
// doesn't re-run on every subsequent insert.
|
|
ownerCacheEvictTo = ownerCacheMaxSize * 3 / 4
|
|
// Discovery resets are throttled to this interval so an unmappable owner kind
|
|
// (typo, uninstalled CRD) can't hammer the API server's discovery endpoint.
|
|
mapperResetInterval = time.Minute
|
|
)
|
|
|
|
func (k *K8sClient) podToContainers(ctx context.Context, pod *corev1.Pod) []container.Container {
|
|
started := time.Time{}
|
|
if pod.Status.StartTime != nil {
|
|
started = pod.Status.StartTime.Time
|
|
}
|
|
|
|
// Build labels map with pod labels, namespace, and owner reference
|
|
labels := make(map[string]string)
|
|
maps.Copy(labels, pod.Labels)
|
|
labels["namespace"] = pod.Namespace
|
|
labels["@k8s.namespace"] = pod.Namespace
|
|
|
|
owners := k.resolveOwnerChain(ctx, pod.Namespace, pod.OwnerReferences)
|
|
if len(owners) > 0 {
|
|
// Immediate owner kept as top-level labels for backward compatibility.
|
|
labels["owner.kind"] = owners[0].Kind
|
|
labels["owner.name"] = owners[0].Name
|
|
labels["owner.key"] = owners[0].Key
|
|
labels["@k8s.owner.count"] = fmt.Sprintf("%d", len(owners))
|
|
}
|
|
for i, owner := range owners {
|
|
prefix := fmt.Sprintf("@k8s.owner.%d.", i)
|
|
labels[prefix+"apiVersion"] = owner.APIVersion
|
|
labels[prefix+"kind"] = owner.Kind
|
|
labels[prefix+"namespace"] = owner.Namespace
|
|
labels[prefix+"name"] = owner.Name
|
|
labels[prefix+"uid"] = owner.UID
|
|
labels[prefix+"key"] = owner.Key
|
|
labels[ownerMembershipLabel(owner.Key)] = "true"
|
|
}
|
|
|
|
var containers []container.Container
|
|
for _, c := range pod.Spec.Containers {
|
|
containers = append(containers, container.Container{
|
|
ID: pod.Namespace + ":" + pod.Name + ":" + c.Name,
|
|
Name: pod.Name + "/" + c.Name,
|
|
Image: c.Image,
|
|
Created: pod.CreationTimestamp.Time,
|
|
State: phaseToState(pod.Status.Phase),
|
|
StartedAt: started,
|
|
Command: strings.Join(c.Command, " "),
|
|
Host: pod.Spec.NodeName,
|
|
Tty: c.TTY,
|
|
Labels: labels,
|
|
Stats: utils.NewRingBuffer[container.ContainerStat](300),
|
|
FullyLoaded: true,
|
|
})
|
|
}
|
|
return containers
|
|
}
|
|
|
|
func (k *K8sClient) resolveOwnerChain(ctx context.Context, namespace string, refs []metav1.OwnerReference) []k8sOwner {
|
|
owners := make([]k8sOwner, 0)
|
|
seen := make(map[string]struct{})
|
|
|
|
for len(refs) > 0 {
|
|
ref := ownerReferenceToFollow(refs)
|
|
if isNodeOwnerReference(ref) {
|
|
break
|
|
}
|
|
owner := newK8sOwner(namespace, ref)
|
|
if _, ok := seen[owner.cacheKey()]; ok {
|
|
break
|
|
}
|
|
seen[owner.cacheKey()] = struct{}{}
|
|
owners = append(owners, owner)
|
|
|
|
next, ok := k.lookupOwnerReferences(ctx, owner)
|
|
if !ok {
|
|
break
|
|
}
|
|
refs = next
|
|
}
|
|
|
|
return owners
|
|
}
|
|
|
|
func ownerReferenceToFollow(refs []metav1.OwnerReference) metav1.OwnerReference {
|
|
for _, ref := range refs {
|
|
if ref.Controller != nil && *ref.Controller {
|
|
return ref
|
|
}
|
|
}
|
|
return refs[0]
|
|
}
|
|
|
|
func isNodeOwnerReference(ref metav1.OwnerReference) bool {
|
|
return ref.APIVersion == "v1" && ref.Kind == "Node"
|
|
}
|
|
|
|
func newK8sOwner(namespace string, ref metav1.OwnerReference) k8sOwner {
|
|
typeKey := ownerTypeKey(ref.APIVersion, ref.Kind)
|
|
// "~" is URL-safe and not allowed in Kubernetes resource names/namespaces,
|
|
// so owner route keys stay readable without colliding with real names.
|
|
key := fmt.Sprintf("%s~%s~%s", typeKey, namespace, ref.Name)
|
|
return k8sOwner{
|
|
APIVersion: ref.APIVersion,
|
|
Kind: ref.Kind,
|
|
Namespace: namespace,
|
|
Name: ref.Name,
|
|
UID: string(ref.UID),
|
|
TypeKey: typeKey,
|
|
Key: key,
|
|
}
|
|
}
|
|
|
|
func (o k8sOwner) cacheKey() string {
|
|
return fmt.Sprintf("%s/%s/%s/%s/%s", o.APIVersion, o.Kind, o.Namespace, o.Name, o.UID)
|
|
}
|
|
|
|
func ownerMembershipLabel(key string) string {
|
|
return "@k8s.owner.key." + base64.RawURLEncoding.EncodeToString([]byte(key))
|
|
}
|
|
|
|
func ownerTypeKey(apiVersion, kind string) string {
|
|
if isKnownK8sOwnerType(apiVersion, kind) {
|
|
return kind
|
|
}
|
|
return strings.ReplaceAll(apiVersion, "/", "~") + "~" + kind
|
|
}
|
|
|
|
func isKnownK8sOwnerType(apiVersion, kind string) bool {
|
|
switch apiVersion + "/" + kind {
|
|
case "apps/v1/Deployment",
|
|
"apps/v1/ReplicaSet",
|
|
"apps/v1/DaemonSet",
|
|
"apps/v1/StatefulSet",
|
|
"batch/v1/Job",
|
|
"batch/v1/CronJob",
|
|
"v1/Pod",
|
|
"v1/Service",
|
|
"v1/ConfigMap",
|
|
"v1/Secret":
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func (k *K8sClient) lookupOwnerReferences(ctx context.Context, owner k8sOwner) ([]metav1.OwnerReference, bool) {
|
|
cacheKey := owner.cacheKey()
|
|
now := time.Now()
|
|
|
|
k.ownerCacheMu.Lock()
|
|
if k.ownerCache == nil {
|
|
k.ownerCache = make(map[string]ownerLookupResult)
|
|
}
|
|
if result, ok := k.ownerCache[cacheKey]; ok && now.Before(result.expiresAt) {
|
|
k.ownerCacheMu.Unlock()
|
|
return result.ownerReferences, result.found
|
|
}
|
|
k.ownerCacheMu.Unlock()
|
|
|
|
refs, ok, cacheable := k.fetchOwnerReferences(ctx, owner)
|
|
|
|
if cacheable {
|
|
k.ownerCacheMu.Lock()
|
|
k.pruneOwnerCache(now)
|
|
k.ownerCache[cacheKey] = ownerLookupResult{ownerReferences: refs, found: ok, expiresAt: now.Add(ownerCacheTTL)}
|
|
k.ownerCacheMu.Unlock()
|
|
}
|
|
|
|
return refs, ok
|
|
}
|
|
|
|
// pruneOwnerCache drops expired entries and, only once the cache grows past
|
|
// ownerCacheMaxSize, evicts arbitrary entries down to ownerCacheEvictTo. This
|
|
// hysteresis (grow to max, drop to evictTo) bounds growth without evicting on
|
|
// every insert, and avoids clearing the map wholesale so a cluster with more
|
|
// than ownerCacheMaxSize live owners doesn't thrash. Callers must hold ownerCacheMu.
|
|
func (k *K8sClient) pruneOwnerCache(now time.Time) {
|
|
for key, result := range k.ownerCache {
|
|
if !now.Before(result.expiresAt) {
|
|
delete(k.ownerCache, key)
|
|
}
|
|
}
|
|
if len(k.ownerCache) <= ownerCacheMaxSize {
|
|
return
|
|
}
|
|
// Map iteration order is randomized, so this evicts an arbitrary subset.
|
|
for key := range k.ownerCache {
|
|
if len(k.ownerCache) <= ownerCacheEvictTo {
|
|
break
|
|
}
|
|
delete(k.ownerCache, key)
|
|
}
|
|
}
|
|
|
|
// resetRESTMapper resets the cached discovery mapper so newly-registered CRDs
|
|
// can be mapped, at most once per mapperResetInterval. Returns true if it reset.
|
|
func (k *K8sClient) resetRESTMapper() bool {
|
|
resetter, ok := k.restMapper.(interface{ Reset() })
|
|
if !ok {
|
|
return false
|
|
}
|
|
|
|
k.mapperMu.Lock()
|
|
defer k.mapperMu.Unlock()
|
|
now := time.Now()
|
|
if !k.lastMapperReset.IsZero() && now.Sub(k.lastMapperReset) < mapperResetInterval {
|
|
return false
|
|
}
|
|
k.lastMapperReset = now
|
|
resetter.Reset()
|
|
return true
|
|
}
|
|
|
|
func (k *K8sClient) fetchOwnerReferences(ctx context.Context, owner k8sOwner) ([]metav1.OwnerReference, bool, bool) {
|
|
if k.DynamicClient == nil || k.restMapper == nil {
|
|
return nil, false, false
|
|
}
|
|
|
|
groupVersion, err := schema.ParseGroupVersion(owner.APIVersion)
|
|
if err != nil {
|
|
log.Debug().Err(err).Str("owner", owner.Key).Msg("failed to parse owner apiVersion")
|
|
return nil, false, false
|
|
}
|
|
|
|
gk := groupVersion.WithKind(owner.Kind).GroupKind()
|
|
mapping, err := k.restMapper.RESTMapping(gk, groupVersion.Version)
|
|
if err != nil {
|
|
// Discovery is cached, so a CRD registered after startup won't map until the
|
|
// cache is reset. Reset and retry, but throttle resets so a permanently
|
|
// unmappable kind can't hammer the discovery endpoint on every pod event.
|
|
if k.resetRESTMapper() {
|
|
mapping, err = k.restMapper.RESTMapping(gk, groupVersion.Version)
|
|
}
|
|
if err != nil {
|
|
log.Debug().Err(err).Str("owner", owner.Key).Msg("failed to map owner resource")
|
|
return nil, false, false
|
|
}
|
|
}
|
|
|
|
var resource dynamic.ResourceInterface
|
|
if mapping.Scope.Name() != meta.RESTScopeNameRoot {
|
|
resource = k.DynamicClient.Resource(mapping.Resource).Namespace(owner.Namespace)
|
|
} else {
|
|
resource = k.DynamicClient.Resource(mapping.Resource)
|
|
}
|
|
|
|
obj, err := resource.Get(ctx, owner.Name, metav1.GetOptions{})
|
|
if err != nil {
|
|
log.Debug().Err(err).Str("owner", owner.Key).Msg("failed to fetch owner resource")
|
|
if ctx.Err() != nil {
|
|
return nil, false, false
|
|
}
|
|
return nil, false, apierrors.IsNotFound(err) || apierrors.IsForbidden(err)
|
|
}
|
|
|
|
return obj.GetOwnerReferences(), true, true
|
|
}
|
|
|
|
func splitK8sFilters(labels container.ContainerLabels) (container.ContainerLabels, container.ContainerLabels) {
|
|
podLabels := make(container.ContainerLabels)
|
|
metadataLabels := make(container.ContainerLabels)
|
|
for key, values := range labels {
|
|
if isK8sMetadataLabel(key) || !isValidK8sLabelKey(key) {
|
|
metadataLabels[key] = values
|
|
} else {
|
|
podLabels[key] = values
|
|
}
|
|
}
|
|
return podLabels, metadataLabels
|
|
}
|
|
|
|
func isK8sMetadataLabel(key string) bool {
|
|
if strings.HasPrefix(key, "@k8s.") {
|
|
return true
|
|
}
|
|
return key == "namespace" ||
|
|
key == "owner.kind" ||
|
|
key == "owner.name" ||
|
|
key == "owner.key"
|
|
}
|
|
|
|
var (
|
|
k8sLabelNamePattern = regexp.MustCompile(`^[A-Za-z0-9]([A-Za-z0-9_.-]{0,61}[A-Za-z0-9])?$`)
|
|
k8sLabelPrefixPattern = regexp.MustCompile(`^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$`)
|
|
)
|
|
|
|
func isValidK8sLabelKey(key string) bool {
|
|
prefix, name, hasPrefix := strings.Cut(key, "/")
|
|
if !hasPrefix {
|
|
name = prefix
|
|
} else if len(prefix) == 0 || len(prefix) > 253 || !k8sLabelPrefixPattern.MatchString(prefix) {
|
|
return false
|
|
}
|
|
return len(name) <= 63 && k8sLabelNamePattern.MatchString(name)
|
|
}
|
|
|
|
func matchesContainerLabels(labels map[string]string, filters container.ContainerLabels) bool {
|
|
for key, values := range filters {
|
|
value, ok := labels[key]
|
|
if !ok {
|
|
return false
|
|
}
|
|
matched := slices.Contains(values, value)
|
|
if !matched {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func (k *K8sClient) ListContainers(ctx context.Context, labels container.ContainerLabels) ([]container.Container, error) {
|
|
podLabels, metadataLabels := splitK8sFilters(labels)
|
|
selector := ""
|
|
if podLabels.Exists() {
|
|
for key, values := range podLabels {
|
|
for _, value := range values {
|
|
if selector != "" {
|
|
selector += ","
|
|
}
|
|
selector += fmt.Sprintf("%s=%s", key, value)
|
|
}
|
|
}
|
|
log.Debug().Str("selector", selector).Msg("Listing containers with labels")
|
|
}
|
|
containerList := lop.Map(k.namespace, func(namespace string, index int) lo.Tuple2[[]container.Container, error] {
|
|
pods, err := k.Clientset.CoreV1().Pods(namespace).List(ctx, metav1.ListOptions{LabelSelector: selector})
|
|
if err != nil {
|
|
return lo.T2[[]container.Container, error](nil, fmt.Errorf("failed to list pods in namespace %s: %w", namespace, err))
|
|
}
|
|
var containers []container.Container
|
|
for _, pod := range pods.Items {
|
|
for _, c := range k.podToContainers(ctx, &pod) {
|
|
if metadataLabels.Exists() && !matchesContainerLabels(c.Labels, metadataLabels) {
|
|
continue
|
|
}
|
|
containers = append(containers, c)
|
|
}
|
|
}
|
|
return lo.T2[[]container.Container, error](containers, nil)
|
|
})
|
|
|
|
var containers []container.Container
|
|
var lastError error
|
|
success := false
|
|
for _, t2 := range containerList {
|
|
items, err := t2.Unpack()
|
|
if err != nil {
|
|
log.Error().Err(err).Msg("failed to fetch containers")
|
|
lastError = err
|
|
continue
|
|
}
|
|
success = true
|
|
containers = append(containers, items...)
|
|
}
|
|
|
|
if !success {
|
|
return nil, lastError
|
|
}
|
|
|
|
return containers, nil
|
|
}
|
|
|
|
func phaseToState(phase corev1.PodPhase) string {
|
|
switch phase {
|
|
case corev1.PodPending:
|
|
return "created"
|
|
case corev1.PodRunning:
|
|
return "running"
|
|
case corev1.PodSucceeded:
|
|
return "exited"
|
|
case corev1.PodFailed:
|
|
return "exited"
|
|
case corev1.PodUnknown:
|
|
return "unknown"
|
|
default:
|
|
return "unknown"
|
|
}
|
|
}
|
|
|
|
func (k *K8sClient) FindContainer(ctx context.Context, id string) (container.Container, error) {
|
|
log.Debug().Str("id", id).Msg("Finding container")
|
|
namespace, podName, containerName := parsePodContainerID(id)
|
|
|
|
pod, err := k.Clientset.CoreV1().Pods(namespace).Get(ctx, podName, metav1.GetOptions{})
|
|
if err != nil {
|
|
return container.Container{}, err
|
|
}
|
|
|
|
for _, c := range k.podToContainers(ctx, pod) {
|
|
if c.ID == id {
|
|
return c, nil
|
|
}
|
|
}
|
|
|
|
return container.Container{}, fmt.Errorf("container %s not found in pod %s", containerName, podName)
|
|
}
|
|
|
|
func (k *K8sClient) ContainerLogs(ctx context.Context, id string, since time.Time, stdType container.StdType) (io.ReadCloser, error) {
|
|
namespace, podName, containerName := parsePodContainerID(id)
|
|
|
|
var lines int64 = 500
|
|
opts := &corev1.PodLogOptions{
|
|
Container: containerName,
|
|
Follow: true,
|
|
Previous: false,
|
|
Timestamps: true,
|
|
SinceTime: &metav1.Time{Time: since},
|
|
TailLines: &lines,
|
|
}
|
|
|
|
return k.Clientset.CoreV1().Pods(namespace).GetLogs(podName, opts).Stream(ctx)
|
|
}
|
|
|
|
func (k *K8sClient) ContainerLogsBetweenDates(ctx context.Context, id string, start time.Time, end time.Time, stdType container.StdType) (io.ReadCloser, error) {
|
|
namespace, podName, containerName := parsePodContainerID(id)
|
|
|
|
opts := &corev1.PodLogOptions{
|
|
Container: containerName,
|
|
Follow: false,
|
|
Timestamps: true,
|
|
SinceTime: &metav1.Time{Time: start},
|
|
}
|
|
|
|
return k.Clientset.CoreV1().Pods(namespace).GetLogs(podName, opts).Stream(ctx)
|
|
}
|
|
|
|
func (k *K8sClient) ContainerEvents(ctx context.Context, ch chan<- container.ContainerEvent) error {
|
|
watchers := lo.Map(k.namespace, func(namespace string, index int) watch.Interface {
|
|
watcher, err := k.Clientset.CoreV1().Pods(namespace).Watch(ctx, metav1.ListOptions{})
|
|
if err != nil {
|
|
log.Error().Err(err).Msg("Failed to watch pods")
|
|
return nil
|
|
}
|
|
return watcher
|
|
})
|
|
|
|
if len(watchers) == 0 {
|
|
return errors.New("no namespaces to watch")
|
|
}
|
|
|
|
wg := sync.WaitGroup{}
|
|
|
|
for _, watcher := range watchers {
|
|
wg.Go(func() {
|
|
for event := range watcher.ResultChan() {
|
|
log.Debug().Interface("event.type", event.Type).Msg("Received kubernetes event")
|
|
pod, ok := event.Object.(*corev1.Pod)
|
|
if !ok {
|
|
continue
|
|
}
|
|
|
|
name := ""
|
|
switch event.Type {
|
|
case "ADDED":
|
|
name = "create"
|
|
case "DELETED":
|
|
name = "destroy"
|
|
case "MODIFIED":
|
|
name = "update"
|
|
}
|
|
|
|
for _, c := range k.podToContainers(ctx, pod) {
|
|
ch <- container.ContainerEvent{
|
|
Name: name,
|
|
ActorID: c.ID,
|
|
Host: pod.Spec.NodeName,
|
|
Time: time.Now(),
|
|
Container: &c,
|
|
}
|
|
}
|
|
}
|
|
})
|
|
}
|
|
|
|
wg.Wait()
|
|
|
|
return nil
|
|
}
|
|
|
|
func (k *K8sClient) ContainerStats(ctx context.Context, id string, stats chan<- container.ContainerStat) error {
|
|
// Stats collection is implemented in stats_collector.go using K8s metrics API
|
|
panic("not implemented - use K8sStatsCollector instead")
|
|
}
|
|
|
|
func (k *K8sClient) Ping(ctx context.Context) error {
|
|
_, err := k.Clientset.CoreV1().Pods("default").List(ctx, metav1.ListOptions{Limit: 1})
|
|
return err
|
|
}
|
|
|
|
func (k *K8sClient) Host() container.Host {
|
|
return k.host
|
|
}
|
|
|
|
func (k *K8sClient) ContainerActions(ctx context.Context, action container.ContainerAction, containerID string) error {
|
|
panic("not implemented")
|
|
}
|
|
|
|
func (k *K8sClient) ContainerAttach(ctx context.Context, id string) (*container.ExecSession, error) {
|
|
namespace, podName, containerName := parsePodContainerID(id)
|
|
log.Debug().Str("container", containerName).Str("pod", podName).Msg("Attaching to pod")
|
|
req := k.Clientset.CoreV1().RESTClient().Post().
|
|
Resource("pods").
|
|
Name(podName).
|
|
Namespace(namespace).
|
|
SubResource("attach")
|
|
|
|
option := &corev1.PodAttachOptions{
|
|
Container: containerName,
|
|
Stdin: true,
|
|
Stdout: true,
|
|
Stderr: true,
|
|
TTY: true,
|
|
}
|
|
|
|
req.VersionedParams(
|
|
option,
|
|
scheme.ParameterCodec,
|
|
)
|
|
|
|
exec, err := remotecommand.NewSPDYExecutor(k.config, "POST", req.URL())
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
stdinReader, stdinWriter := io.Pipe()
|
|
stdoutReader, stdoutWriter := io.Pipe()
|
|
|
|
// Create TerminalSizeQueue for dynamic resizing
|
|
sizeQueue := &terminalSizeQueue{
|
|
resizeChan: make(chan remotecommand.TerminalSize, 1),
|
|
}
|
|
|
|
go func() {
|
|
err := exec.StreamWithContext(ctx, remotecommand.StreamOptions{
|
|
Stdin: stdinReader,
|
|
Stdout: stdoutWriter,
|
|
Tty: true,
|
|
TerminalSizeQueue: sizeQueue,
|
|
})
|
|
if err != nil {
|
|
log.Error().Err(err).Msg("Error streaming command")
|
|
}
|
|
}()
|
|
|
|
// Create resize closure that sends to the queue
|
|
resizeFn := func(width uint, height uint) error {
|
|
select {
|
|
case sizeQueue.resizeChan <- remotecommand.TerminalSize{Width: uint16(width), Height: uint16(height)}:
|
|
return nil
|
|
default:
|
|
return fmt.Errorf("resize queue full")
|
|
}
|
|
}
|
|
|
|
return &container.ExecSession{
|
|
Writer: stdinWriter,
|
|
Reader: stdoutReader,
|
|
Resize: resizeFn,
|
|
}, nil
|
|
}
|
|
|
|
// terminalSizeQueue implements remotecommand.TerminalSizeQueue
|
|
type terminalSizeQueue struct {
|
|
resizeChan chan remotecommand.TerminalSize
|
|
}
|
|
|
|
func (t *terminalSizeQueue) Next() *remotecommand.TerminalSize {
|
|
size, ok := <-t.resizeChan
|
|
if !ok {
|
|
return nil
|
|
}
|
|
return &size
|
|
}
|
|
|
|
func (k *K8sClient) ContainerExec(ctx context.Context, id string, cmd []string) (*container.ExecSession, error) {
|
|
namespace, podName, containerName := parsePodContainerID(id)
|
|
log.Debug().Str("container", containerName).Str("pod", podName).Msg("Executing command in pod")
|
|
req := k.Clientset.CoreV1().RESTClient().Post().
|
|
Resource("pods").
|
|
Name(podName).
|
|
Namespace(namespace).
|
|
SubResource("exec")
|
|
|
|
option := &corev1.PodExecOptions{
|
|
Command: cmd,
|
|
Container: containerName,
|
|
Stdin: true,
|
|
Stdout: true,
|
|
Stderr: true,
|
|
TTY: true,
|
|
}
|
|
|
|
req.VersionedParams(
|
|
option,
|
|
scheme.ParameterCodec,
|
|
)
|
|
|
|
exec, err := remotecommand.NewSPDYExecutor(k.config, "POST", req.URL())
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
stdinReader, stdinWriter := io.Pipe()
|
|
stdoutReader, stdoutWriter := io.Pipe()
|
|
|
|
// Create TerminalSizeQueue for dynamic resizing
|
|
sizeQueue := &terminalSizeQueue{
|
|
resizeChan: make(chan remotecommand.TerminalSize, 1),
|
|
}
|
|
|
|
go func() {
|
|
err := exec.StreamWithContext(ctx, remotecommand.StreamOptions{
|
|
Stdin: stdinReader,
|
|
Stdout: stdoutWriter,
|
|
Tty: true,
|
|
TerminalSizeQueue: sizeQueue,
|
|
})
|
|
if err != nil {
|
|
log.Error().Err(err).Msg("Error streaming command")
|
|
}
|
|
}()
|
|
|
|
// Create resize closure that sends to the queue
|
|
resizeFn := func(width uint, height uint) error {
|
|
select {
|
|
case sizeQueue.resizeChan <- remotecommand.TerminalSize{Width: uint16(width), Height: uint16(height)}:
|
|
return nil
|
|
default:
|
|
return fmt.Errorf("resize queue full")
|
|
}
|
|
}
|
|
|
|
return &container.ExecSession{
|
|
Writer: stdinWriter,
|
|
Reader: stdoutReader,
|
|
Resize: resizeFn,
|
|
}, nil
|
|
}
|
|
|
|
// Helper function to parse pod and container names from container ID
|
|
func parsePodContainerID(id string) (string, string, string) {
|
|
parts := strings.Split(id, ":")
|
|
return parts[0], parts[1], parts[2]
|
|
}
|