mirror of
https://github.com/rancher/k3k.git
synced 2026-05-23 09:43:11 +00:00
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:
123
k3k-kubelet/controller/syncer/events.go
Normal file
123
k3k-kubelet/controller/syncer/events.go
Normal 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)
|
||||
}
|
||||
366
k3k-kubelet/controller/syncer/events_test.go
Normal file
366
k3k-kubelet/controller/syncer/events_test.go
Normal 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...))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
424
k3k-kubelet/translate/host_test.go
Normal file
424
k3k-kubelet/translate/host_test.go
Normal 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"])
|
||||
}
|
||||
@@ -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{""},
|
||||
|
||||
Reference in New Issue
Block a user