From 52f1dc324e5ead55eaf7d11c2c1f980d8af3109f Mon Sep 17 00:00:00 2001 From: Sreyas Natarajan Date: Thu, 24 Aug 2023 15:49:57 -0700 Subject: [PATCH] Add v1a2 webconsole request controllers --- .../controllers.go | 5 + .../v1alpha2/webconsolerequest_controller.go | 216 ++++++++++++++++++ .../v1alpha2/webconsolerequest_intg_test.go | 136 +++++++++++ .../v1alpha2/webconsolerequest_suite_test.go | 36 +++ .../v1alpha2/webconsolerequest_unit_test.go | 125 ++++++++++ pkg/context/webconsolerequest_context.go | 19 +- 6 files changed, 534 insertions(+), 3 deletions(-) create mode 100644 controllers/virtualmachinewebconsolerequest/v1alpha2/webconsolerequest_controller.go create mode 100644 controllers/virtualmachinewebconsolerequest/v1alpha2/webconsolerequest_intg_test.go create mode 100644 controllers/virtualmachinewebconsolerequest/v1alpha2/webconsolerequest_suite_test.go create mode 100644 controllers/virtualmachinewebconsolerequest/v1alpha2/webconsolerequest_unit_test.go diff --git a/controllers/virtualmachinewebconsolerequest/controllers.go b/controllers/virtualmachinewebconsolerequest/controllers.go index 9964710f8..85cec14f1 100644 --- a/controllers/virtualmachinewebconsolerequest/controllers.go +++ b/controllers/virtualmachinewebconsolerequest/controllers.go @@ -7,10 +7,15 @@ import ( "sigs.k8s.io/controller-runtime/pkg/manager" "github.com/vmware-tanzu/vm-operator/controllers/virtualmachinewebconsolerequest/v1alpha1" + "github.com/vmware-tanzu/vm-operator/controllers/virtualmachinewebconsolerequest/v1alpha2" "github.com/vmware-tanzu/vm-operator/pkg/context" + "github.com/vmware-tanzu/vm-operator/pkg/lib" ) // AddToManager adds the controller to the provided manager. func AddToManager(ctx *context.ControllerManagerContext, mgr manager.Manager) error { + if lib.IsVMServiceV1Alpha2FSSEnabled() { + return v1alpha2.AddToManager(ctx, mgr) + } return v1alpha1.AddToManager(ctx, mgr) } diff --git a/controllers/virtualmachinewebconsolerequest/v1alpha2/webconsolerequest_controller.go b/controllers/virtualmachinewebconsolerequest/v1alpha2/webconsolerequest_controller.go new file mode 100644 index 000000000..4a44ff6bd --- /dev/null +++ b/controllers/virtualmachinewebconsolerequest/v1alpha2/webconsolerequest_controller.go @@ -0,0 +1,216 @@ +// Copyright (c) 2022-2023 VMware, Inc. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package v1alpha2 + +import ( + goctx "context" + + "fmt" + "reflect" + "strings" + "time" + + "github.com/go-logr/logr" + "github.com/pkg/errors" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller" + "sigs.k8s.io/controller-runtime/pkg/manager" + + vmopv1 "github.com/vmware-tanzu/vm-operator/api/v1alpha2" + "github.com/vmware-tanzu/vm-operator/pkg/context" + "github.com/vmware-tanzu/vm-operator/pkg/patch" + "github.com/vmware-tanzu/vm-operator/pkg/record" + "github.com/vmware-tanzu/vm-operator/pkg/vmprovider" +) + +const ( + DefaultExpiryTime = time.Second * 120 + UUIDLabelKey = "vmoperator.vmware.com/webconsolerequest-uuid" + + ProxyAddrServiceName = "kube-apiserver-lb-svc" + ProxyAddrServiceNamespace = "kube-system" +) + +// AddToManager adds this package's controller to the provided manager. +func AddToManager(ctx *context.ControllerManagerContext, mgr manager.Manager) error { + var ( + controlledType = &vmopv1.VirtualMachineWebConsoleRequest{} + controlledTypeName = reflect.TypeOf(controlledType).Elem().Name() + + controllerNameShort = fmt.Sprintf("%s-controller", strings.ToLower(controlledTypeName)) + controllerNameLong = fmt.Sprintf("%s/%s/%s", ctx.Namespace, ctx.Name, controllerNameShort) + ) + + r := NewReconciler( + mgr.GetClient(), + ctrl.Log.WithName("controllers").WithName(controlledTypeName), + record.New(mgr.GetEventRecorderFor(controllerNameLong)), + ctx.VMProviderA2, + ) + + return ctrl.NewControllerManagedBy(mgr). + For(controlledType). + WithOptions(controller.Options{MaxConcurrentReconciles: 1}). + Complete(r) +} + +func NewReconciler( + client client.Client, + logger logr.Logger, + recorder record.Recorder, + vmProvider vmprovider.VirtualMachineProviderInterfaceA2) *Reconciler { + return &Reconciler{ + Client: client, + Logger: logger, + Recorder: recorder, + VMProvider: vmProvider, + } +} + +// Reconciler reconciles a WebConsoleRequest object. +type Reconciler struct { + client.Client + Logger logr.Logger + Recorder record.Recorder + VMProvider vmprovider.VirtualMachineProviderInterfaceA2 +} + +// -kubebuilder:rbac:groups=vmoperator.vmware.com,resources=virtualmachinewebconsolerequests,verbs=get;list;watch;create;update;patch;delete +// -kubebuilder:rbac:groups=vmoperator.vmware.com,resources=virtualmachinewebconsolerequests/status,verbs=get;update;patch +// -kubebuilder:rbac:groups=vmoperator.vmware.com,resources=virtualmachines,verbs=get;list +// -kubebuilder:rbac:groups="",resources=services,verbs=get;list;watch +// -kubebuilder:rbac:groups="",resources=services/status,verbs=get + +func (r *Reconciler) Reconcile(ctx goctx.Context, req ctrl.Request) (_ ctrl.Result, reterr error) { + webconsolerequest := &vmopv1.VirtualMachineWebConsoleRequest{} + err := r.Get(ctx, req.NamespacedName, webconsolerequest) + if err != nil { + return ctrl.Result{}, client.IgnoreNotFound(err) + } + + webConsoleRequestCtx := &context.WebConsoleRequestContextA2{ + Context: ctx, + Logger: ctrl.Log.WithName("WebConsoleRequest").WithValues("name", req.NamespacedName), + WebConsoleRequest: webconsolerequest, + VM: &vmopv1.VirtualMachine{}, + } + + done, err := r.ReconcileEarlyNormal(webConsoleRequestCtx) + if err != nil { + webConsoleRequestCtx.Logger.Error(err, "failed to expire WebConsoleRequest") + return ctrl.Result{}, err + } + if done { + return ctrl.Result{}, nil + } + + err = r.Get(ctx, client.ObjectKey{Name: webconsolerequest.Spec.Name, Namespace: webconsolerequest.Namespace}, webConsoleRequestCtx.VM) + if err != nil { + r.Recorder.Warn(webConsoleRequestCtx.WebConsoleRequest, "VirtualMachine Not Found", "") + webConsoleRequestCtx.Logger.Error(err, "failed to get subject vm %s", webconsolerequest.Spec.Name) + return ctrl.Result{}, errors.Wrapf(err, "failed to get subject vm %s", webconsolerequest.Spec.Name) + } + + patchHelper, err := patch.NewHelper(webconsolerequest, r.Client) + if err != nil { + return ctrl.Result{}, fmt.Errorf("failed to init patch helper for %s: %w", webConsoleRequestCtx, err) + } + defer func() { + if err := patchHelper.Patch(ctx, webconsolerequest); err != nil { + if reterr == nil { + reterr = err + } + webConsoleRequestCtx.Logger.Error(err, "patch failed") + } + }() + + if err := r.ReconcileNormal(webConsoleRequestCtx); err != nil { + webConsoleRequestCtx.Logger.Error(err, "failed to reconcile WebConsoleRequest") + return ctrl.Result{}, err + } + + return ctrl.Result{Requeue: true, RequeueAfter: DefaultExpiryTime}, nil +} + +func (r *Reconciler) ReconcileEarlyNormal(ctx *context.WebConsoleRequestContextA2) (bool, error) { + expiryTime := ctx.WebConsoleRequest.Status.ExpiryTime + nowTime := metav1.Now() + if !expiryTime.IsZero() && !nowTime.Before(&expiryTime) { + err := r.Delete(ctx, ctx.WebConsoleRequest) + if client.IgnoreNotFound(err) != nil { + return false, errors.Wrapf(err, "failed to delete webconsolerequest") + } + ctx.Logger.Info("Deleted expired WebConsoleRequest") + return true, nil + } + + if ctx.WebConsoleRequest.Status.Response != "" && + ctx.WebConsoleRequest.Status.ProxyAddr != "" { + // If the response and proxy address are already set, no need to reconcile anymore + ctx.Logger.Info("Response and proxy address already set, skip reconciling") + return true, nil + } + + return false, nil +} + +func (r *Reconciler) ReconcileNormal(ctx *context.WebConsoleRequestContextA2) error { + ctx.Logger.Info("Reconciling WebConsoleRequest") + defer func() { + ctx.Logger.Info("Finished reconciling WebConsoleRequest") + }() + + ticket, err := r.VMProvider.GetVirtualMachineWebMKSTicket(ctx, ctx.VM, ctx.WebConsoleRequest.Spec.PublicKey) + if err != nil { + return errors.Wrapf(err, "failed to get webmksticket") + } + r.Recorder.EmitEvent(ctx.WebConsoleRequest, "Acquired Ticket", nil, false) + + ctx.WebConsoleRequest.Status.Response = ticket + ctx.WebConsoleRequest.Status.ExpiryTime = metav1.NewTime(metav1.Now().Add(DefaultExpiryTime)) + + // Retrieve the proxy address from the load balancer service ingress IP. + proxySvc := &corev1.Service{} + proxySvcObjectKey := client.ObjectKey{Name: ProxyAddrServiceName, Namespace: ProxyAddrServiceNamespace} + err = r.Get(ctx, proxySvcObjectKey, proxySvc) + if err != nil { + return errors.Wrapf(err, "failed to get proxy address service %s", proxySvcObjectKey) + } + if len(proxySvc.Status.LoadBalancer.Ingress) == 0 { + return errors.Errorf("no ingress found for proxy address service %s", proxySvcObjectKey) + } + + ctx.WebConsoleRequest.Status.ProxyAddr = proxySvc.Status.LoadBalancer.Ingress[0].IP + + // Add UUID as a Label to the current WebConsoleRequest resource after acquiring the ticket. + // This will be used when validating the connection request from users to the web console URL. + if ctx.WebConsoleRequest.Labels == nil { + ctx.WebConsoleRequest.Labels = make(map[string]string) + } + ctx.WebConsoleRequest.Labels[UUIDLabelKey] = string(ctx.WebConsoleRequest.UID) + + err = r.ReconcileOwnerReferences(ctx) + if err != nil { + return err + } + + return nil +} + +func (r *Reconciler) ReconcileOwnerReferences(ctx *context.WebConsoleRequestContextA2) error { + isController := true + ownerRef := metav1.OwnerReference{ + APIVersion: ctx.VM.APIVersion, + Kind: ctx.VM.Kind, + Name: ctx.VM.Name, + UID: ctx.VM.UID, + Controller: &isController, + } + + ctx.WebConsoleRequest.SetOwnerReferences([]metav1.OwnerReference{ownerRef}) + return nil +} diff --git a/controllers/virtualmachinewebconsolerequest/v1alpha2/webconsolerequest_intg_test.go b/controllers/virtualmachinewebconsolerequest/v1alpha2/webconsolerequest_intg_test.go new file mode 100644 index 000000000..c6185f046 --- /dev/null +++ b/controllers/virtualmachinewebconsolerequest/v1alpha2/webconsolerequest_intg_test.go @@ -0,0 +1,136 @@ +// Copyright (c) 2022 VMware, Inc. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package v1alpha2_test + +import ( + "context" + "time" + + . "github.com/onsi/ginkgo" + . "github.com/onsi/gomega" + + corev1 "k8s.io/api/core/v1" + k8serrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + + vmopv1 "github.com/vmware-tanzu/vm-operator/api/v1alpha2" + webconsolerequest "github.com/vmware-tanzu/vm-operator/controllers/virtualmachinewebconsolerequest/v1alpha1" + "github.com/vmware-tanzu/vm-operator/test/builder" +) + +func intgTests() { + Describe("Invoking WebConsoleRequest controller tests", webConsoleRequestReconcile) +} + +func webConsoleRequestReconcile() { + var ( + ctx *builder.IntegrationTestContext + wcr *vmopv1.VirtualMachineWebConsoleRequest + vm *vmopv1.VirtualMachine + proxySvc *corev1.Service + ) + + getWebConsoleRequest := func(ctx *builder.IntegrationTestContext, objKey types.NamespacedName) *vmopv1.VirtualMachineWebConsoleRequest { + wcr := &vmopv1.VirtualMachineWebConsoleRequest{} + if err := ctx.Client.Get(ctx, objKey, wcr); err != nil { + return nil + } + return wcr + } + + BeforeEach(func() { + ctx = suite.NewIntegrationTestContext() + + vm = &vmopv1.VirtualMachine{ + ObjectMeta: metav1.ObjectMeta{ + Name: "dummy-vm", + Namespace: ctx.Namespace, + }, + Spec: vmopv1.VirtualMachineSpec{ + ImageName: "dummy-image", + PowerState: vmopv1.VirtualMachinePowerStateOn, + }, + } + + _, publicKeyPem := builder.WebConsoleRequestKeyPair() + + wcr = &vmopv1.VirtualMachineWebConsoleRequest{ + ObjectMeta: metav1.ObjectMeta{ + Name: "dummy-wcr", + Namespace: ctx.Namespace, + }, + Spec: vmopv1.VirtualMachineWebConsoleRequestSpec{ + Name: vm.Name, + PublicKey: publicKeyPem, + }, + } + + proxySvc = &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: webconsolerequest.ProxyAddrServiceName, + Namespace: webconsolerequest.ProxyAddrServiceNamespace, + }, + Spec: corev1.ServiceSpec{ + Ports: []corev1.ServicePort{ + { + Name: "dummy-proxy-port", + Port: 443, + }, + }, + }, + } + + fakeVMProvider.Lock() + defer fakeVMProvider.Unlock() + fakeVMProvider.GetVirtualMachineWebMKSTicketFn = func(ctx context.Context, vm *vmopv1.VirtualMachine, pubKey string) (string, error) { + return "some-fake-webmksticket", nil + } + }) + + AfterEach(func() { + ctx.AfterEach() + ctx = nil + fakeVMProvider.Reset() + }) + + Context("Reconcile", func() { + BeforeEach(func() { + Expect(ctx.Client.Create(ctx, vm)).To(Succeed()) + Expect(ctx.Client.Create(ctx, wcr)).To(Succeed()) + Expect(ctx.Client.Create(ctx, proxySvc)).To(Succeed()) + proxySvc.Status = corev1.ServiceStatus{ + LoadBalancer: corev1.LoadBalancerStatus{ + Ingress: []corev1.LoadBalancerIngress{ + { + IP: "192.168.0.1", + }, + }, + }, + } + Expect(ctx.Client.Status().Update(ctx, proxySvc)).To(Succeed()) + }) + + AfterEach(func() { + err := ctx.Client.Delete(ctx, wcr) + Expect(err == nil || k8serrors.IsNotFound(err)).To(BeTrue()) + err = ctx.Client.Delete(ctx, vm) + Expect(err == nil || k8serrors.IsNotFound(err)).To(BeTrue()) + }) + + It("resource successfully created", func() { + Eventually(func() bool { + wcr = getWebConsoleRequest(ctx, types.NamespacedName{Name: wcr.Name, Namespace: wcr.Namespace}) + if wcr != nil && wcr.Status.Response != "" { + return true + } + return false + }).Should(BeTrue(), "waiting for webconsolerequest to be") + Expect(wcr.Status.ProxyAddr).To(Equal("192.168.0.1")) + Expect(wcr.Status.Response).ToNot(BeEmpty()) + Expect(wcr.Status.ExpiryTime.Time).To(BeTemporally("~", time.Now(), webconsolerequest.DefaultExpiryTime)) + Expect(wcr.Labels).To(HaveKeyWithValue(webconsolerequest.UUIDLabelKey, string(wcr.UID))) + }) + }) +} diff --git a/controllers/virtualmachinewebconsolerequest/v1alpha2/webconsolerequest_suite_test.go b/controllers/virtualmachinewebconsolerequest/v1alpha2/webconsolerequest_suite_test.go new file mode 100644 index 000000000..c701f928a --- /dev/null +++ b/controllers/virtualmachinewebconsolerequest/v1alpha2/webconsolerequest_suite_test.go @@ -0,0 +1,36 @@ +// Copyright (c) 2022 VMware, Inc. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package v1alpha2_test + +import ( + "testing" + + . "github.com/onsi/ginkgo" + + ctrlmgr "sigs.k8s.io/controller-runtime/pkg/manager" + + "github.com/vmware-tanzu/vm-operator/controllers/virtualmachinewebconsolerequest/v1alpha2" + ctrlContext "github.com/vmware-tanzu/vm-operator/pkg/context" + "github.com/vmware-tanzu/vm-operator/pkg/lib" + providerfake "github.com/vmware-tanzu/vm-operator/pkg/vmprovider/fake" + "github.com/vmware-tanzu/vm-operator/test/builder" +) + +var fakeVMProvider = providerfake.NewVMProviderA2() + +var suite = builder.NewTestSuiteForControllerWithFSS( + v1alpha2.AddToManager, + func(ctx *ctrlContext.ControllerManagerContext, _ ctrlmgr.Manager) error { + ctx.VMProviderA2 = fakeVMProvider + return nil + }, + map[string]bool{lib.VMServiceV1Alpha2FSS: true}) + +func TestWebConsoleRequest(t *testing.T) { + suite.Register(t, "WebConsoleRequest controller suite", intgTests, unitTests) +} + +var _ = BeforeSuite(suite.BeforeSuite) + +var _ = AfterSuite(suite.AfterSuite) diff --git a/controllers/virtualmachinewebconsolerequest/v1alpha2/webconsolerequest_unit_test.go b/controllers/virtualmachinewebconsolerequest/v1alpha2/webconsolerequest_unit_test.go new file mode 100644 index 000000000..3f143e03f --- /dev/null +++ b/controllers/virtualmachinewebconsolerequest/v1alpha2/webconsolerequest_unit_test.go @@ -0,0 +1,125 @@ +// Copyright (c) 2022 VMware, Inc. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package v1alpha2_test + +import ( + "context" + "time" + + . "github.com/onsi/ginkgo" + . "github.com/onsi/gomega" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" + + vmopv1 "github.com/vmware-tanzu/vm-operator/api/v1alpha2" + webconsolerequest "github.com/vmware-tanzu/vm-operator/controllers/virtualmachinewebconsolerequest/v1alpha2" + vmopContext "github.com/vmware-tanzu/vm-operator/pkg/context" + providerfake "github.com/vmware-tanzu/vm-operator/pkg/vmprovider/fake" + "github.com/vmware-tanzu/vm-operator/test/builder" +) + +func unitTests() { + Describe("Invoking WebConsoleRequest Reconcile", unitTestsReconcile) +} + +func unitTestsReconcile() { + + var ( + initObjects []client.Object + ctx *builder.UnitTestContextForController + + reconciler *webconsolerequest.Reconciler + wcrCtx *vmopContext.WebConsoleRequestContextA2 + wcr *vmopv1.VirtualMachineWebConsoleRequest + vm *vmopv1.VirtualMachine + proxySvc *corev1.Service + ) + + BeforeEach(func() { + vm = &vmopv1.VirtualMachine{ + ObjectMeta: metav1.ObjectMeta{ + Name: "dummy-vm", + }, + } + + wcr = &vmopv1.VirtualMachineWebConsoleRequest{ + ObjectMeta: metav1.ObjectMeta{ + Name: "dummy-wcr", + }, + Spec: vmopv1.VirtualMachineWebConsoleRequestSpec{ + Name: vm.Name, + PublicKey: "", + }, + } + + proxySvc = &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: webconsolerequest.ProxyAddrServiceName, + Namespace: webconsolerequest.ProxyAddrServiceNamespace, + }, + Status: corev1.ServiceStatus{ + LoadBalancer: corev1.LoadBalancerStatus{ + Ingress: []corev1.LoadBalancerIngress{ + { + IP: "dummy-proxy-ip", + }, + }, + }, + }, + } + }) + + JustBeforeEach(func() { + ctx = suite.NewUnitTestContextForController(initObjects...) + reconciler = webconsolerequest.NewReconciler( + ctx.Client, + ctx.Logger, + ctx.Recorder, + ctx.VMProviderA2, + ) + fakeVMProvider = ctx.VMProviderA2.(*providerfake.VMProviderA2) + + wcrCtx = &vmopContext.WebConsoleRequestContextA2{ + Context: ctx, + Logger: ctx.Logger.WithName(wcr.Name), + WebConsoleRequest: wcr, + VM: vm, + } + }) + + AfterEach(func() { + ctx.AfterEach() + ctx = nil + initObjects = nil + reconciler = nil + fakeVMProvider.Reset() + }) + + Context("ReconcileNormal", func() { + BeforeEach(func() { + initObjects = append(initObjects, wcr, vm, proxySvc) + }) + + JustBeforeEach(func() { + fakeVMProvider.GetVirtualMachineWebMKSTicketFn = func(ctx context.Context, vm *vmopv1.VirtualMachine, pubKey string) (string, error) { + return "some-fake-webmksticket", nil + } + }) + + When("NoOp", func() { + It("returns success", func() { + err := reconciler.ReconcileNormal(wcrCtx) + Expect(err).ToNot(HaveOccurred()) + + Expect(wcrCtx.WebConsoleRequest.Status.ProxyAddr).To(Equal("dummy-proxy-ip")) + Expect(wcrCtx.WebConsoleRequest.Status.Response).ToNot(BeEmpty()) + Expect(wcrCtx.WebConsoleRequest.Status.ExpiryTime.Time).To(BeTemporally("~", time.Now(), webconsolerequest.DefaultExpiryTime)) + // Checking the label key only because UID will not be set to a resource during unit test. + Expect(wcrCtx.WebConsoleRequest.Labels).To(HaveKey(webconsolerequest.UUIDLabelKey)) + }) + }) + }) +} diff --git a/pkg/context/webconsolerequest_context.go b/pkg/context/webconsolerequest_context.go index 6296c7334..96a25894c 100644 --- a/pkg/context/webconsolerequest_context.go +++ b/pkg/context/webconsolerequest_context.go @@ -9,17 +9,30 @@ import ( "github.com/go-logr/logr" - vmopv1 "github.com/vmware-tanzu/vm-operator/api/v1alpha1" + "github.com/vmware-tanzu/vm-operator/api/v1alpha1" + vmopv1 "github.com/vmware-tanzu/vm-operator/api/v1alpha2" ) // WebConsoleRequestContext is the context used for WebConsoleRequestControllers. type WebConsoleRequestContext struct { context.Context Logger logr.Logger - WebConsoleRequest *vmopv1.WebConsoleRequest - VM *vmopv1.VirtualMachine + WebConsoleRequest *v1alpha1.WebConsoleRequest + VM *v1alpha1.VirtualMachine } func (v *WebConsoleRequestContext) String() string { return fmt.Sprintf("%s %s/%s", v.WebConsoleRequest.GroupVersionKind(), v.WebConsoleRequest.Namespace, v.WebConsoleRequest.Name) } + +// WebConsoleRequestContextA2 is the context used for WebConsoleRequestControllers. +type WebConsoleRequestContextA2 struct { + context.Context + Logger logr.Logger + WebConsoleRequest *vmopv1.VirtualMachineWebConsoleRequest + VM *vmopv1.VirtualMachine +} + +func (v *WebConsoleRequestContextA2) String() string { + return fmt.Sprintf("%s %s/%s", v.WebConsoleRequest.GroupVersionKind(), v.WebConsoleRequest.Namespace, v.WebConsoleRequest.Name) +}