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.
This commit is contained in:
Kevin McDermott
2026-05-21 14:02:07 +01:00
parent fbaac329d1
commit 4795ef5ae7
7 changed files with 1000 additions and 18 deletions

View File

@@ -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)
}

View File

@@ -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...))
}

View File

@@ -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
}

View File

@@ -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())

View File

@@ -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
}

View File

@@ -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"])
}

View File

@@ -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{""},