From 4795ef5ae7d0b54a5e5ea96a2c9243e859ca08d1 Mon Sep 17 00:00:00 2001 From: Kevin McDermott Date: Thu, 21 May 2026 14:02:07 +0100 Subject: [PATCH 1/3] Mirror events for the cluster into the virtual cluster. Adds a controller to watch for virtual cluster namespace events and mirrors them into the virtual cluster. --- k3k-kubelet/controller/syncer/events.go | 123 ++++++ k3k-kubelet/controller/syncer/events_test.go | 366 ++++++++++++++++ k3k-kubelet/kubelet.go | 52 ++- k3k-kubelet/node.go | 4 + k3k-kubelet/translate/host.go | 47 ++ k3k-kubelet/translate/host_test.go | 424 +++++++++++++++++++ pkg/controller/cluster/agent/shared.go | 2 +- 7 files changed, 1000 insertions(+), 18 deletions(-) create mode 100644 k3k-kubelet/controller/syncer/events.go create mode 100644 k3k-kubelet/controller/syncer/events_test.go create mode 100644 k3k-kubelet/translate/host_test.go diff --git a/k3k-kubelet/controller/syncer/events.go b/k3k-kubelet/controller/syncer/events.go new file mode 100644 index 0000000..1b02fdf --- /dev/null +++ b/k3k-kubelet/controller/syncer/events.go @@ -0,0 +1,123 @@ +package syncer + +import ( + "context" + "fmt" + "strings" + + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/client-go/tools/record" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/manager" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + + corev1 "k8s.io/api/core/v1" + ctrl "sigs.k8s.io/controller-runtime" + + "github.com/rancher/k3k/k3k-kubelet/translate" +) + +const ( + eventControllerName = "event-syncer" +) + +type EventSyncer struct { + // virtEventRecorder is a K8s EventRecorder to emit events into the + // virtual cluster. + virtEventRecorder record.EventRecorder + + // SyncerContext contains all client information for host and virtual + // cluster. + *SyncerContext +} + +func (s *EventSyncer) Name() string { + return eventControllerName +} + +// AddEventSyncer adds event syncer controller to the manager of the virtual +// cluster. +func AddEventSyncer(ctx context.Context, virtMgr, hostMgr manager.Manager, clusterName, clusterNamespace string, virtEventRecorder record.EventRecorder) error { + reconciler := EventSyncer{ + virtEventRecorder: virtEventRecorder, + SyncerContext: &SyncerContext{ + VirtualClient: virtMgr.GetClient(), + HostClient: hostMgr.GetClient(), + Translator: translate.ToHostTranslator{ + ClusterName: clusterName, + ClusterNamespace: clusterNamespace, + }, + ClusterName: clusterName, + ClusterNamespace: clusterNamespace, + }, + } + + name := reconciler.Translator.TranslateName(clusterNamespace, eventControllerName) + + return ctrl.NewControllerManagedBy(hostMgr). + Named(name). + For(&corev1.Event{}). + Complete(&reconciler) +} + +// Reconcile implements reconcile.Reconciler and synchronizes relevant events +// between the host and virtual clusters. +func (s *EventSyncer) Reconcile(ctx context.Context, req reconcile.Request) (reconcile.Result, error) { + logger := ctrl.LoggerFrom(ctx).WithValues("cluster", s.ClusterName, "clusterNamespace", s.ClusterName) + + event := &corev1.Event{} + if err := s.HostClient.Get(ctx, req.NamespacedName, event); err != nil { + return reconcile.Result{}, client.IgnoreNotFound(fmt.Errorf("could not load event: %w", err)) + } + + if event.InvolvedObject.Kind != "Pod" { + return reconcile.Result{}, nil + } + + virtualRef := s.Translator.TranslateObjectReferenceFrom(event.InvolvedObject) + + // Look up the corresponding object in the virtual cluster. + virtObj := &unstructured.Unstructured{} + virtObj.SetAPIVersion(virtualRef.APIVersion) + virtObj.SetKind(virtualRef.Kind) + + if err := s.VirtualClient.Get(ctx, client.ObjectKey{Name: virtualRef.Name, Namespace: virtualRef.Namespace}, virtObj); err != nil { + if client.IgnoreNotFound(err) != nil { + return reconcile.Result{}, fmt.Errorf("could not load virtual object: %w", err) + } + + logger.Info("virtual object not found, skipping event emission", + "virtualInvolvedObject.kind", virtualRef.Kind, + "virtualInvolvedObject.name", virtualRef.Name, + ) + + return reconcile.Result{}, nil + } + + logger.V(3).Info("Emitting event into virtual cluster", "virtObj.name", + virtObj.GetName(), "virtObj.namespace", virtObj.GetNamespace(), "reason", + event.Reason, "message", event.Message, "type", event.Type) + + message := translateEventMessage( + event.Message, + event.InvolvedObject.Name, event.InvolvedObject.Namespace, + virtualRef.Name, virtualRef.Namespace, + ) + s.virtEventRecorder.Event(virtObj, event.Type, event.Reason, message) + + return reconcile.Result{}, nil +} + +// translateEventMessage replaces occurrences of the host-cluster pod name and +// "namespace/name" pattern in the event message with the corresponding +// virtual-cluster values, so the emitted event refers to virtual coordinates. +func translateEventMessage(message, hostName, hostNamespace, virtualName, virtualNamespace string) string { + // Replace the combined "namespace/name" first to avoid a partial match + // turning the namespace portion into the virtual namespace before the name + // is replaced. + if hostNamespace != "" && virtualNamespace != "" { + message = strings.ReplaceAll(message, hostNamespace+"/"+hostName, virtualNamespace+"/"+virtualName) + } + // Replace any remaining standalone occurrences of the host pod name. + return strings.ReplaceAll(message, hostName, virtualName) +} diff --git a/k3k-kubelet/controller/syncer/events_test.go b/k3k-kubelet/controller/syncer/events_test.go new file mode 100644 index 0000000..203281f --- /dev/null +++ b/k3k-kubelet/controller/syncer/events_test.go @@ -0,0 +1,366 @@ +package syncer + +import ( + "context" + "fmt" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + "github.com/rancher/k3k/k3k-kubelet/translate" +) + +func TestEventSyncerReconcile(t *testing.T) { + scheme := runtime.NewScheme() + require.NoError(t, corev1.AddToScheme(scheme)) + + tests := map[string]struct { + // the event being reconciled + receivedEvent *corev1.Event + // virtualObj is stored in the virtual fake client, simulating the resource + // in the virtual cluster that the event's InvolvedObject maps to. + virtualObj *unstructured.Unstructured + wantEvent *capturedEvent + }{ + "normal event is re-emitted via virtEventRecorder": { + receivedEvent: &corev1.Event{ + ObjectMeta: metav1.ObjectMeta{ + Name: "nginx-test-mycluster-6e67696e782b746573742b6d79636c7573746572.18b194827e6b49da", + Namespace: "k3k-mycluster", + }, + InvolvedObject: corev1.ObjectReference{ + APIVersion: "v1", + Kind: "Pod", + Name: "nginx-test-mycluster-6e67696e782b746573742b6d79636c7573746572", + Namespace: "k3k-mycluster", + }, + Type: corev1.EventTypeNormal, + Reason: "Started", + Message: "Container started successfully", + }, + virtualObj: &unstructured.Unstructured{Object: map[string]any{ + "apiVersion": "v1", + "kind": "Pod", + "metadata": map[string]any{ + "name": "nginx", + "namespace": "test", + "uid": "pod-virtual-uid", + "resourceVersion": "1", + }, + }}, + wantEvent: &capturedEvent{ + Object: &unstructured.Unstructured{Object: map[string]any{ + "apiVersion": "v1", + "kind": "Pod", + "metadata": map[string]any{ + "name": "nginx", + "namespace": "test", + "uid": "pod-virtual-uid", + }, + }}, + EventType: corev1.EventTypeNormal, + Reason: "Started", + Message: "Container started successfully", + }, + }, + "warning event is re-emitted via virtEventRecorder": { + receivedEvent: &corev1.Event{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-event-warning", + Namespace: "host-ns", + }, + InvolvedObject: corev1.ObjectReference{ + APIVersion: "v1", + Kind: "Pod", + Name: "nginx-test-mycluster-6e67696e782b746573742b6d79636c7573746572", + Namespace: "k3k-mycluster", + }, + Type: corev1.EventTypeWarning, + Reason: "BackOff", + Message: "Back-off restarting failed container", + }, + virtualObj: &unstructured.Unstructured{Object: map[string]any{ + "apiVersion": "v1", + "kind": "Pod", + "metadata": map[string]any{ + "name": "nginx", + "namespace": "test", + "uid": "pod-virtual-uid", + "resourceVersion": "1", + }, + }}, + wantEvent: &capturedEvent{ + Object: &unstructured.Unstructured{Object: map[string]any{ + "apiVersion": "v1", + "kind": "Pod", + "metadata": map[string]any{ + "name": "nginx", + "namespace": "test", + "uid": "pod-virtual-uid", + }, + }}, + EventType: corev1.EventTypeWarning, + Reason: "BackOff", + Message: "Back-off restarting failed container", + }, + }, + "event with empty type and reason is still forwarded": { + receivedEvent: &corev1.Event{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-event-empty", + Namespace: "host-ns", + }, + InvolvedObject: corev1.ObjectReference{ + APIVersion: "v1", + Kind: "Pod", + Name: "nginx-test-mycluster-6e67696e782b746573742b6d79636c7573746572", + Namespace: "k3k-mycluster", + }, + Type: "", + Reason: "", + Message: "some message", + }, + virtualObj: &unstructured.Unstructured{Object: map[string]any{ + "apiVersion": "v1", + "kind": "Pod", + "metadata": map[string]any{ + "name": "nginx", + "namespace": "test", + "uid": "pod-virtual-uid", + "resourceVersion": "1", + }, + }}, + wantEvent: &capturedEvent{ + Object: &unstructured.Unstructured{Object: map[string]any{ + "apiVersion": "v1", + "kind": "Pod", + "metadata": map[string]any{ + "name": "nginx", + "namespace": "test", + "uid": "pod-virtual-uid", + }, + }}, + EventType: "", + Reason: "", + Message: "some message", + }, + }, + "message containing host namespace/name is translated to virtual name/namespace": { + receivedEvent: &corev1.Event{ + ObjectMeta: metav1.ObjectMeta{ + Name: "nginx-test-mycluster-6e67696e782b746573742b6d79636c7573746572.18b194827e6b49da", + Namespace: "k3k-mycluster", + }, + InvolvedObject: corev1.ObjectReference{ + APIVersion: "v1", + Kind: "Pod", + Name: "nginx-test-mycluster-6e67696e782b746573742b6d79636c7573746572", + Namespace: "k3k-mycluster", + }, + Type: corev1.EventTypeNormal, + Reason: "Scheduled", + Message: "Successfully assigned k3k-mycluster/nginx-test-mycluster-6e67696e782b746573742b6d79636c7573746572 to localhost.localdomain", + }, + virtualObj: &unstructured.Unstructured{Object: map[string]any{ + "apiVersion": "v1", + "kind": "Pod", + "metadata": map[string]any{ + "name": "nginx", + "namespace": "test", + "uid": "pod-virtual-uid", + "resourceVersion": "1", + }, + }}, + wantEvent: &capturedEvent{ + Object: &unstructured.Unstructured{Object: map[string]any{ + "apiVersion": "v1", + "kind": "Pod", + "metadata": map[string]any{ + "name": "nginx", + "namespace": "test", + "uid": "pod-virtual-uid", + }, + }}, + EventType: corev1.EventTypeNormal, + Reason: "Scheduled", + Message: "Successfully assigned test/nginx to localhost.localdomain", + }, + }, + "non-Pod InvolvedObject kind skips reconciliation": { + receivedEvent: &corev1.Event{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-event-non-pod", + Namespace: "k3k-mycluster", + }, + InvolvedObject: corev1.ObjectReference{ + APIVersion: "v1", + Kind: "ConfigMap", + Name: "nginx-test-mycluster-6e67696e782b746573742b6d79636c7573746572", + Namespace: "k3k-mycluster", + }, + Type: corev1.EventTypeNormal, + Reason: "Updated", + Message: "ConfigMap updated", + }, + // wantEvent is nil: non-Pod InvolvedObject is skipped + wantEvent: nil, + }, + "non-reversible translated name skips event emission": { + receivedEvent: &corev1.Event{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-event-non-reversible", + Namespace: "k3k-mycluster", + }, + InvolvedObject: corev1.ObjectReference{ + APIVersion: "v1", + Kind: "Pod", + Name: "some-non-translated-name", + Namespace: "k3k-mycluster", + }, + Type: corev1.EventTypeNormal, + Reason: "Started", + Message: "Container started", + }, + // virtualObj is nil: object not found in virtual cluster → event is skipped + wantEvent: nil, + }, + } + + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + hostFakeClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(tt.receivedEvent). + Build() + + virtFakeObjs := []client.Object{} + if tt.virtualObj != nil { + virtFakeObjs = append(virtFakeObjs, tt.virtualObj) + } + + virtualFakeClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(virtFakeObjs...). + Build() + + recorder := &fakeEventRecorder{} + + syncer := &EventSyncer{ + virtEventRecorder: recorder, + SyncerContext: &SyncerContext{ + HostClient: hostFakeClient, + VirtualClient: virtualFakeClient, + Translator: translate.ToHostTranslator{ + ClusterName: "mycluster", + ClusterNamespace: "k3k-mycluster", + }, + ClusterName: "mycluster", + ClusterNamespace: "k3k-mycluster", + }, + } + + result, err := syncer.Reconcile(context.Background(), reconcile.Request{ + NamespacedName: types.NamespacedName{ + Name: tt.receivedEvent.Name, + Namespace: tt.receivedEvent.Namespace, + }, + }) + + require.NoError(t, err) + assert.Equal(t, reconcile.Result{}, result) + + if tt.wantEvent == nil { + assert.Empty(t, recorder.events) + } else { + require.Len(t, recorder.events, 1) + got := recorder.events[0] + assert.Equal(t, tt.wantEvent.EventType, got.EventType) + assert.Equal(t, tt.wantEvent.Reason, got.Reason) + assert.Equal(t, tt.wantEvent.Message, got.Message) + wantObj := tt.wantEvent.Object.(*unstructured.Unstructured) + gotObj := got.Object.(*unstructured.Unstructured) + assert.Equal(t, wantObj.GetName(), gotObj.GetName()) + assert.Equal(t, wantObj.GetNamespace(), gotObj.GetNamespace()) + assert.Equal(t, wantObj.GetUID(), gotObj.GetUID()) + } + }) + } +} + +func TestEventSyncerReconcileNotFound(t *testing.T) { + scheme := runtime.NewScheme() + require.NoError(t, corev1.AddToScheme(scheme)) + + fakeClient := fake.NewClientBuilder(). + WithScheme(scheme). + Build() + + recorder := &fakeEventRecorder{} + + syncer := &EventSyncer{ + virtEventRecorder: recorder, + SyncerContext: &SyncerContext{ + HostClient: fakeClient, + VirtualClient: fakeClient, + Translator: translate.ToHostTranslator{ + ClusterName: "mycluster", + ClusterNamespace: "host-ns", + }, + ClusterName: "mycluster", + ClusterNamespace: "host-ns", + }, + } + + result, err := syncer.Reconcile(context.Background(), reconcile.Request{ + NamespacedName: types.NamespacedName{ + Name: "non-existent-event", + Namespace: "host-ns", + }, + }) + + require.NoError(t, err) + assert.Equal(t, reconcile.Result{}, result) + + assert.Empty(t, recorder.events) +} + +// This fake is used instead of the one in the client-go package because we need to check that the events are sent to the correct namespace. +// +// The client-go fake doesn't support namespaces, so we implement our own that captures the events in memory for verification. +type capturedEvent struct { + Object runtime.Object + EventType string + Reason string + Message string +} + +type fakeEventRecorder struct { + events []capturedEvent +} + +func (f *fakeEventRecorder) Event(object runtime.Object, eventtype, reason, message string) { + f.events = append(f.events, capturedEvent{ + Object: object, + EventType: eventtype, + Reason: reason, + Message: message, + }) +} + +func (f *fakeEventRecorder) Eventf(object runtime.Object, eventtype, reason, messageFmt string, args ...any) { + f.Event(object, eventtype, reason, fmt.Sprintf(messageFmt, args...)) +} + +func (f *fakeEventRecorder) AnnotatedEventf(object runtime.Object, _ map[string]string, eventtype, reason, messageFmt string, args ...any) { + f.Event(object, eventtype, reason, fmt.Sprintf(messageFmt, args...)) +} diff --git a/k3k-kubelet/kubelet.go b/k3k-kubelet/kubelet.go index 72fff72..4aa8130 100644 --- a/k3k-kubelet/kubelet.go +++ b/k3k-kubelet/kubelet.go @@ -4,6 +4,7 @@ import ( "context" "crypto/x509" "errors" + "path" "time" "github.com/go-logr/logr" @@ -17,6 +18,7 @@ import ( "k8s.io/client-go/kubernetes" "k8s.io/client-go/rest" "k8s.io/client-go/tools/clientcmd" + "k8s.io/client-go/tools/record" "k8s.io/client-go/util/retry" "k8s.io/klog/v2" "sigs.k8s.io/controller-runtime/pkg/cache" @@ -24,6 +26,7 @@ import ( corev1 "k8s.io/api/core/v1" clientgoscheme "k8s.io/client-go/kubernetes/scheme" + corev1client "k8s.io/client-go/kubernetes/typed/core/v1" clientcmdapi "k8s.io/client-go/tools/clientcmd/api" ctrl "sigs.k8s.io/controller-runtime" ctrlruntimeclient "sigs.k8s.io/controller-runtime/pkg/client" @@ -62,6 +65,9 @@ type kubelet struct { node *nodeutil.Node logger logr.Logger token string + + virtEventRecorder record.EventRecorder + eb record.EventBroadcaster } func newKubelet(ctx context.Context, c *config) (*kubelet, error) { @@ -138,7 +144,13 @@ func newKubelet(ctx context.Context, c *config) (*kubelet, error) { return nil, errors.New("unable to remove pod mutating webhook for virtual cluster: " + err.Error()) } - if err := addControllers(ctx, hostMgr, virtualMgr, c, hostClient); err != nil { + controllerName := c.AgentHostname + eb := record.NewBroadcaster(record.WithContext(ctx)) + eb.StartRecordingToSink(&corev1client.EventSinkImpl{Interface: virtClient.CoreV1().Events(corev1.NamespaceAll)}) + + virtEventRecorder := eb.NewRecorder(virtualScheme, corev1.EventSource{Component: path.Join(controllerName, "pod-controller")}) + + if err := addControllers(ctx, hostMgr, virtualMgr, c, hostClient, virtEventRecorder); err != nil { return nil, errors.New("failed to add controller: " + err.Error()) } @@ -161,20 +173,21 @@ func newKubelet(ctx context.Context, c *config) (*kubelet, error) { } return &kubelet{ - virtualCluster: virtualCluster, - - name: c.AgentHostname, - hostConfig: hostConfig, - hostClient: hostClient, - virtConfig: virtConfig, - virtClient: virtClient, - hostMgr: hostMgr, - virtualMgr: virtualMgr, - agentIP: clusterIP, - logger: logger, - token: c.Token, - dnsIP: dnsService.Spec.ClusterIP, - port: c.KubeletPort, + virtualCluster: virtualCluster, + name: controllerName, + hostConfig: hostConfig, + hostClient: hostClient, + virtConfig: virtConfig, + virtClient: virtClient, + hostMgr: hostMgr, + virtualMgr: virtualMgr, + agentIP: clusterIP, + logger: logger, + token: c.Token, + dnsIP: dnsService.Spec.ClusterIP, + port: c.KubeletPort, + virtEventRecorder: virtEventRecorder, + eb: eb, }, nil } @@ -230,6 +243,7 @@ func (k *kubelet) start(ctx context.Context) { if err := k.node.Err(); err != nil { k.logger.Error(err, "node stopped with an error") } + defer k.eb.Shutdown() k.logger.Info("node exited successfully") } @@ -254,7 +268,7 @@ func (k *kubelet) newProviderFunc(cfg config) nodeutil.NewProviderFunc { cfg.MirrorHostNodes, ) - return utilProvider, &provider.Node{}, err + return utilProvider, nil, err } } @@ -330,7 +344,7 @@ func kubeconfigBytes(url string, serverCA, clientCert, clientKey []byte) ([]byte return clientcmd.Write(*config) } -func addControllers(ctx context.Context, hostMgr, virtualMgr manager.Manager, c *config, hostClient ctrlruntimeclient.Client) error { +func addControllers(ctx context.Context, hostMgr, virtualMgr manager.Manager, c *config, hostClient ctrlruntimeclient.Client, virtEventRecorder record.EventRecorder) error { var cluster v1beta1.Cluster objKey := types.NamespacedName{ @@ -374,5 +388,9 @@ func addControllers(ctx context.Context, hostMgr, virtualMgr manager.Manager, c return errors.New("failed to add priorityclass controller: " + err.Error()) } + if err := syncer.AddEventSyncer(ctx, virtualMgr, hostMgr, c.ClusterName, c.ClusterNamespace, virtEventRecorder); err != nil { + return errors.New("failed to add event syncer controller: " + err.Error()) + } + return nil } diff --git a/k3k-kubelet/node.go b/k3k-kubelet/node.go index 0cc2ad1..52c2534 100644 --- a/k3k-kubelet/node.go +++ b/k3k-kubelet/node.go @@ -33,6 +33,10 @@ func (k *kubelet) registerNode(agentIP, podIP string, cfg config) error { nodeutil.WithClient(k.virtClient), nodeutil.AttachProviderRoutes(mux), nodeOpt(mux, tlsConfig, cfg.KubeletPort), + func(c *nodeutil.NodeConfig) error { + c.EventRecorder = k.virtEventRecorder + return nil + }, ) if err != nil { return errors.New("unable to start kubelet: " + err.Error()) diff --git a/k3k-kubelet/translate/host.go b/k3k-kubelet/translate/host.go index 214084b..d5ae471 100644 --- a/k3k-kubelet/translate/host.go +++ b/k3k-kubelet/translate/host.go @@ -7,6 +7,8 @@ import ( "k8s.io/apimachinery/pkg/types" "sigs.k8s.io/controller-runtime/pkg/client" + corev1 "k8s.io/api/core/v1" + "github.com/rancher/k3k/pkg/apis/k3k.io/v1beta1" "github.com/rancher/k3k/pkg/controller" ) @@ -142,3 +144,48 @@ func (t *ToHostTranslator) NamespacedName(obj client.Object) types.NamespacedNam Name: t.TranslateName(obj.GetNamespace(), obj.GetName()), } } + +// TranslateObjectReferenceFrom translates a host-cluster ObjectReference back to +// virtual-cluster coordinates by reversing the name encoding applied by TranslateName. +// If the name cannot be reversed (e.g. it was truncated by SafeConcatName), the +// original host name and namespace are preserved. +func (t *ToHostTranslator) TranslateObjectReferenceFrom(ref corev1.ObjectReference) *corev1.ObjectReference { + result := *ref.DeepCopy() + if name, namespace, ok := t.reverseTranslateName(ref.Name); ok { + result.Name = name + result.Namespace = namespace + } + + return &result +} + +// reverseTranslateName attempts to recover the original virtual name and namespace +// from a host-cluster translated name. TranslateName encodes the original values as +// a hex string suffix (hex("name+namespace+clusterName")), which this method decodes. +// Returns ok=false when the name was truncated and cannot be reversed. +func (t *ToHostTranslator) reverseTranslateName(translatedName string) (name, namespace string, ok bool) { + for i := len(translatedName) - 1; i >= 0; i-- { + if translatedName[i] != '-' { + continue + } + + decoded, err := hex.DecodeString(translatedName[i+1:]) + if err != nil { + continue + } + + parts := strings.SplitN(string(decoded), "+", 3) + switch len(parts) { + case 2: + if parts[1] == t.ClusterName { + return parts[0], "", true + } + case 3: + if parts[2] == t.ClusterName { + return parts[0], parts[1], true + } + } + } + + return "", "", false +} diff --git a/k3k-kubelet/translate/host_test.go b/k3k-kubelet/translate/host_test.go new file mode 100644 index 0000000..33eb66c --- /dev/null +++ b/k3k-kubelet/translate/host_test.go @@ -0,0 +1,424 @@ +package translate + +import ( + "encoding/hex" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "k8s.io/apimachinery/pkg/types" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + "github.com/rancher/k3k/pkg/apis/k3k.io/v1beta1" +) + +func newTranslator(clusterName, clusterNamespace string) *ToHostTranslator { + return &ToHostTranslator{ + ClusterName: clusterName, + ClusterNamespace: clusterNamespace, + } +} + +func TestNewHostTranslator(t *testing.T) { + cluster := &v1beta1.Cluster{ + ObjectMeta: metav1.ObjectMeta{ + Name: "my-cluster", + Namespace: "my-namespace", + }, + } + + tr := NewHostTranslator(cluster) + assert.Equal(t, "my-cluster", tr.ClusterName) + assert.Equal(t, "my-namespace", tr.ClusterNamespace) +} + +func TestTranslateTo(t *testing.T) { + tests := []struct { + name string + clusterName string + clusterNamespace string + obj *corev1.ConfigMap + verify func(t *testing.T, obj *corev1.ConfigMap, tr *ToHostTranslator) + }{ + { + name: "sets tracking annotations and label", + clusterName: "mycluster", + clusterNamespace: "host-ns", + obj: &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: "my-cm", + Namespace: "virt-ns", + }, + }, + verify: func(t *testing.T, obj *corev1.ConfigMap, tr *ToHostTranslator) { + assert.Equal(t, "my-cm", obj.Annotations[ResourceNameAnnotation]) + assert.Equal(t, "virt-ns", obj.Annotations[ResourceNamespaceAnnotation]) + assert.Equal(t, "mycluster", obj.Labels[ClusterNameLabel]) + }, + }, + { + name: "sets translated name and host namespace", + clusterName: "mycluster", + clusterNamespace: "host-ns", + obj: &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: "my-cm", + Namespace: "virt-ns", + }, + }, + verify: func(t *testing.T, obj *corev1.ConfigMap, tr *ToHostTranslator) { + assert.Equal(t, tr.TranslateName("virt-ns", "my-cm"), obj.Name) + assert.Equal(t, "host-ns", obj.Namespace) + }, + }, + { + name: "clears resource version and UID", + clusterName: "mycluster", + clusterNamespace: "host-ns", + obj: &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: "my-cm", + Namespace: "virt-ns", + ResourceVersion: "123", + UID: types.UID("abc-uid"), + }, + }, + verify: func(t *testing.T, obj *corev1.ConfigMap, tr *ToHostTranslator) { + assert.Empty(t, obj.ResourceVersion) + assert.Empty(t, obj.UID) + }, + }, + { + name: "clears owner references and finalizers", + clusterName: "mycluster", + clusterNamespace: "host-ns", + obj: &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: "my-cm", + Namespace: "virt-ns", + OwnerReferences: []metav1.OwnerReference{ + {Name: "owner", UID: "owner-uid"}, + }, + Finalizers: []string{"my-finalizer"}, + }, + }, + verify: func(t *testing.T, obj *corev1.ConfigMap, _ *ToHostTranslator) { + assert.Nil(t, obj.OwnerReferences) + assert.Nil(t, obj.Finalizers) + }, + }, + { + name: "preserves existing annotations and labels", + clusterName: "mycluster", + clusterNamespace: "host-ns", + obj: &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: "my-cm", + Namespace: "virt-ns", + Annotations: map[string]string{"existing-annotation": "value"}, + Labels: map[string]string{"existing-label": "value"}, + }, + }, + verify: func(t *testing.T, obj *corev1.ConfigMap, _ *ToHostTranslator) { + assert.Equal(t, "value", obj.Annotations["existing-annotation"]) + assert.Equal(t, "value", obj.Labels["existing-label"]) + }, + }, + { + name: "handles nil annotations and labels", + clusterName: "mycluster", + clusterNamespace: "host-ns", + obj: &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: "my-cm", + Namespace: "virt-ns", + }, + }, + verify: func(t *testing.T, obj *corev1.ConfigMap, _ *ToHostTranslator) { + assert.NotNil(t, obj.Annotations) + assert.NotNil(t, obj.Labels) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tr := newTranslator(tt.clusterName, tt.clusterNamespace) + tr.TranslateTo(tt.obj) + tt.verify(t, tt.obj, tr) + }) + } +} + +func TestTranslateFrom(t *testing.T) { + tests := []struct { + name string + obj *corev1.ConfigMap + verify func(t *testing.T, obj *corev1.ConfigMap) + }{ + { + name: "restores name and namespace from annotations", + obj: &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: "translated-name", + Namespace: "host-ns", + Annotations: map[string]string{ + ResourceNameAnnotation: "original-name", + ResourceNamespaceAnnotation: "original-ns", + }, + Labels: map[string]string{ + ClusterNameLabel: "mycluster", + }, + }, + }, + verify: func(t *testing.T, obj *corev1.ConfigMap) { + assert.Equal(t, "original-name", obj.Name) + assert.Equal(t, "original-ns", obj.Namespace) + }, + }, + { + name: "removes tracking annotations", + obj: &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: "translated-name", + Namespace: "host-ns", + Annotations: map[string]string{ + ResourceNameAnnotation: "original-name", + ResourceNamespaceAnnotation: "original-ns", + "other-annotation": "keep-me", + }, + Labels: map[string]string{ + ClusterNameLabel: "mycluster", + }, + }, + }, + verify: func(t *testing.T, obj *corev1.ConfigMap) { + assert.NotContains(t, obj.Annotations, ResourceNameAnnotation) + assert.NotContains(t, obj.Annotations, ResourceNamespaceAnnotation) + assert.Equal(t, "keep-me", obj.Annotations["other-annotation"]) + }, + }, + { + name: "removes cluster name label", + obj: &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: "translated-name", + Namespace: "host-ns", + Annotations: map[string]string{ + ResourceNameAnnotation: "original-name", + ResourceNamespaceAnnotation: "original-ns", + }, + Labels: map[string]string{ + ClusterNameLabel: "mycluster", + "other-label": "keep-me", + }, + }, + }, + verify: func(t *testing.T, obj *corev1.ConfigMap) { + assert.NotContains(t, obj.Labels, ClusterNameLabel) + assert.Equal(t, "keep-me", obj.Labels["other-label"]) + }, + }, + { + name: "clears resource version and UID", + obj: &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: "translated-name", + Namespace: "host-ns", + ResourceVersion: "999", + UID: types.UID("some-uid"), + Annotations: map[string]string{ + ResourceNameAnnotation: "original-name", + ResourceNamespaceAnnotation: "original-ns", + }, + Labels: map[string]string{ + ClusterNameLabel: "mycluster", + }, + }, + }, + verify: func(t *testing.T, obj *corev1.ConfigMap) { + assert.Empty(t, obj.ResourceVersion) + assert.Empty(t, obj.UID) + }, + }, + { + name: "clears owner references", + obj: &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: "translated-name", + Namespace: "host-ns", + Annotations: map[string]string{ + ResourceNameAnnotation: "original-name", + ResourceNamespaceAnnotation: "original-ns", + }, + Labels: map[string]string{ + ClusterNameLabel: "mycluster", + }, + OwnerReferences: []metav1.OwnerReference{ + {Name: "host-owner", UID: "host-owner-uid"}, + }, + }, + }, + verify: func(t *testing.T, obj *corev1.ConfigMap) { + assert.Nil(t, obj.OwnerReferences) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tr := newTranslator("mycluster", "host-ns") + tr.TranslateFrom(tt.obj) + tt.verify(t, tt.obj) + }) + } +} + +func TestTranslateName(t *testing.T) { + tr := newTranslator("mycluster", "host-ns") + + tests := []struct { + name string + namespace string + objName string + verify func(t *testing.T, result string) + }{ + { + name: "namespaced resource produces valid name", + namespace: "virt-ns", + objName: "my-cm", + verify: func(t *testing.T, result string) { + assert.NotEmpty(t, result) + assert.LessOrEqual(t, len(result), 63) + }, + }, + { + name: "non-namespaced resource omits namespace from name", + namespace: "", + objName: "my-priority-class", + verify: func(t *testing.T, result string) { + assert.NotEmpty(t, result) + assert.LessOrEqual(t, len(result), 63) + // Without a namespace segment, name should not contain "--" + assert.NotContains(t, result, "--") + }, + }, + { + name: "namespaced result encodes suffix deterministically", + namespace: "virt-ns", + objName: "my-cm", + verify: func(t *testing.T, result string) { + // Calling TranslateName again must return the same value + result2 := tr.TranslateName("virt-ns", "my-cm") + assert.Equal(t, result, result2) + }, + }, + { + name: "long name suffix is hex-encoded", + namespace: "virt-ns", + objName: strings.Repeat("b", 50), + verify: func(t *testing.T, result string) { + // For names that exceed 63 chars, SafeConcatName appends a short hex snippet + // (5 or 6 chars) from SHA256. Pad to even length before decoding so that + // odd-length suffixes are accepted. + parts := strings.Split(result, "-") + suffix := parts[len(parts)-1] + + padded := suffix + if len(padded)%2 != 0 { + padded = "0" + padded + } + + _, err := hex.DecodeString(padded) + assert.NoError(t, err, "suffix should be valid hex") + }, + }, + { + name: "different names produce different results", + namespace: "virt-ns", + objName: "my-cm", + verify: func(t *testing.T, result string) { + other := tr.TranslateName("virt-ns", "other-cm") + assert.NotEqual(t, result, other) + }, + }, + { + name: "different namespaces produce different results", + namespace: "virt-ns", + objName: "my-cm", + verify: func(t *testing.T, result string) { + other := tr.TranslateName("other-ns", "my-cm") + assert.NotEqual(t, result, other) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := tr.TranslateName(tt.namespace, tt.objName) + tt.verify(t, result) + }) + } +} + +func TestTranslateNameLong(t *testing.T) { + tr := newTranslator("mycluster", "host-ns") + + longName := strings.Repeat("a", 60) + result := tr.TranslateName("virt-ns", longName) + + assert.LessOrEqual(t, len(result), 63, "translated name must fit within 63 characters") +} + +func TestNamespacedName(t *testing.T) { + tr := newTranslator("mycluster", "host-ns") + + obj := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: "my-cm", + Namespace: "virt-ns", + }, + } + + nn := tr.NamespacedName(obj) + + assert.Equal(t, "host-ns", nn.Namespace) + assert.Equal(t, tr.TranslateName("virt-ns", "my-cm"), nn.Name) +} + +func TestTranslateToFromRoundtrip(t *testing.T) { + tr := newTranslator("mycluster", "host-ns") + + original := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: "my-cm", + Namespace: "virt-ns", + Annotations: map[string]string{ + "user-annotation": "user-value", + }, + Labels: map[string]string{ + "user-label": "user-value", + }, + }, + } + + // Deep-copy the original metadata we want to verify round-trip for + origName := original.Name + origNamespace := original.Namespace + + tr.TranslateTo(original) + + // After TranslateTo the name/namespace should be host-side values + assert.NotEqual(t, origName, original.Name) + assert.Equal(t, "host-ns", original.Namespace) + + tr.TranslateFrom(original) + + // After TranslateFrom we should be back to the virtual-cluster values + assert.Equal(t, origName, original.Name) + assert.Equal(t, origNamespace, original.Namespace) + assert.Equal(t, "user-value", original.Annotations["user-annotation"]) + assert.Equal(t, "user-value", original.Labels["user-label"]) +} diff --git a/pkg/controller/cluster/agent/shared.go b/pkg/controller/cluster/agent/shared.go index 465d158..91f7093 100644 --- a/pkg/controller/cluster/agent/shared.go +++ b/pkg/controller/cluster/agent/shared.go @@ -402,7 +402,7 @@ func (s *SharedAgent) role(ctx context.Context) error { { APIGroups: []string{""}, Resources: []string{"events"}, - Verbs: []string{"create"}, + Verbs: []string{"create", "get", "list", "watch"}, }, { APIGroups: []string{""}, From 14cc78bc43eee1cfdc89e876500d0b8434b1bd5e Mon Sep 17 00:00:00 2001 From: Kevin McDermott Date: Fri, 22 May 2026 12:25:49 +0100 Subject: [PATCH 2/3] Update k3k-kubelet/translate/host_test.go Co-authored-by: Hussein Galal --- k3k-kubelet/translate/host_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/k3k-kubelet/translate/host_test.go b/k3k-kubelet/translate/host_test.go index 33eb66c..18b8eb8 100644 --- a/k3k-kubelet/translate/host_test.go +++ b/k3k-kubelet/translate/host_test.go @@ -85,7 +85,7 @@ func TestTranslateTo(t *testing.T) { UID: types.UID("abc-uid"), }, }, - verify: func(t *testing.T, obj *corev1.ConfigMap, tr *ToHostTranslator) { + verify: func(t *testing.T, obj *corev1.ConfigMap, _ *ToHostTranslator) { assert.Empty(t, obj.ResourceVersion) assert.Empty(t, obj.UID) }, From f24a978edf3e1279ff5a85a05c52df5e18c591d6 Mon Sep 17 00:00:00 2001 From: Kevin McDermott Date: Fri, 22 May 2026 12:28:54 +0100 Subject: [PATCH 3/3] Switch to loading a Pod. Load the pod explicitly rather than the unstructured from the InvolvedObject reference. --- k3k-kubelet/controller/syncer/events.go | 14 +++++--------- k3k-kubelet/controller/syncer/events_test.go | 2 +- 2 files changed, 6 insertions(+), 10 deletions(-) diff --git a/k3k-kubelet/controller/syncer/events.go b/k3k-kubelet/controller/syncer/events.go index 1b02fdf..e629ad4 100644 --- a/k3k-kubelet/controller/syncer/events.go +++ b/k3k-kubelet/controller/syncer/events.go @@ -5,7 +5,6 @@ import ( "fmt" "strings" - "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/client-go/tools/record" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/manager" @@ -77,11 +76,8 @@ func (s *EventSyncer) Reconcile(ctx context.Context, req reconcile.Request) (rec virtualRef := s.Translator.TranslateObjectReferenceFrom(event.InvolvedObject) // Look up the corresponding object in the virtual cluster. - virtObj := &unstructured.Unstructured{} - virtObj.SetAPIVersion(virtualRef.APIVersion) - virtObj.SetKind(virtualRef.Kind) - - if err := s.VirtualClient.Get(ctx, client.ObjectKey{Name: virtualRef.Name, Namespace: virtualRef.Namespace}, virtObj); err != nil { + virtPod := &corev1.Pod{} + if err := s.VirtualClient.Get(ctx, client.ObjectKey{Name: virtualRef.Name, Namespace: virtualRef.Namespace}, virtPod); err != nil { if client.IgnoreNotFound(err) != nil { return reconcile.Result{}, fmt.Errorf("could not load virtual object: %w", err) } @@ -94,8 +90,8 @@ func (s *EventSyncer) Reconcile(ctx context.Context, req reconcile.Request) (rec return reconcile.Result{}, nil } - logger.V(3).Info("Emitting event into virtual cluster", "virtObj.name", - virtObj.GetName(), "virtObj.namespace", virtObj.GetNamespace(), "reason", + logger.V(3).Info("Emitting event into virtual cluster", "virtPod.name", + virtPod.GetName(), "virtPod.namespace", virtPod.GetNamespace(), "reason", event.Reason, "message", event.Message, "type", event.Type) message := translateEventMessage( @@ -103,7 +99,7 @@ func (s *EventSyncer) Reconcile(ctx context.Context, req reconcile.Request) (rec event.InvolvedObject.Name, event.InvolvedObject.Namespace, virtualRef.Name, virtualRef.Namespace, ) - s.virtEventRecorder.Event(virtObj, event.Type, event.Reason, message) + s.virtEventRecorder.Event(virtPod, event.Type, event.Reason, message) return reconcile.Result{}, nil } diff --git a/k3k-kubelet/controller/syncer/events_test.go b/k3k-kubelet/controller/syncer/events_test.go index 203281f..1c69aa9 100644 --- a/k3k-kubelet/controller/syncer/events_test.go +++ b/k3k-kubelet/controller/syncer/events_test.go @@ -288,7 +288,7 @@ func TestEventSyncerReconcile(t *testing.T) { assert.Equal(t, tt.wantEvent.Reason, got.Reason) assert.Equal(t, tt.wantEvent.Message, got.Message) wantObj := tt.wantEvent.Object.(*unstructured.Unstructured) - gotObj := got.Object.(*unstructured.Unstructured) + gotObj := got.Object.(*corev1.Pod) assert.Equal(t, wantObj.GetName(), gotObj.GetName()) assert.Equal(t, wantObj.GetNamespace(), gotObj.GetNamespace()) assert.Equal(t, wantObj.GetUID(), gotObj.GetUID())