diff --git a/src/cli/go.mod b/src/cli/go.mod index 2da6a46666..96ad87b02f 100644 --- a/src/cli/go.mod +++ b/src/cli/go.mod @@ -6,6 +6,8 @@ require ( github.com/deckhouse/virtualization/api v0.15.0 github.com/fatih/color v1.18.0 github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 + github.com/onsi/ginkgo/v2 v2.23.3 + github.com/onsi/gomega v1.37.0 github.com/povsister/scp v0.0.0-20250504051308-e467f71ea63c github.com/spf13/cobra v1.9.1 github.com/spf13/pflag v1.0.7 @@ -34,9 +36,11 @@ require ( github.com/go-openapi/jsonpointer v0.21.0 // indirect github.com/go-openapi/jsonreference v0.21.0 // indirect github.com/go-openapi/swag v0.23.0 // indirect + github.com/go-task/slim-sprig/v3 v3.0.0 // indirect github.com/gogo/protobuf v1.3.2 // indirect github.com/google/gnostic-models v0.7.0 // indirect github.com/google/go-cmp v0.7.0 // indirect + github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83 // indirect github.com/google/uuid v1.6.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/josharian/intern v1.0.0 // indirect @@ -64,6 +68,7 @@ require ( golang.org/x/net v0.47.0 // indirect golang.org/x/oauth2 v0.27.0 // indirect golang.org/x/time v0.9.0 // indirect + golang.org/x/tools v0.38.0 // indirect google.golang.org/protobuf v1.36.5 // indirect gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect diff --git a/src/cli/go.sum b/src/cli/go.sum index 08dc251c8f..9277f421f1 100644 --- a/src/cli/go.sum +++ b/src/cli/go.sum @@ -63,7 +63,6 @@ github.com/go-openapi/swag v0.19.14/go.mod h1:QYRuS/SOXUCsnplDa677K7+DxSOj6IPNl/ github.com/go-openapi/swag v0.21.1/go.mod h1:QYRuS/SOXUCsnplDa677K7+DxSOj6IPNl/eQntq43wQ= github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE= github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ= -github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0 h1:p104kn46Q8WdvHunIJ9dAyjPVtrBPhSr3KT2yUst43I= github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE= github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= @@ -94,8 +93,8 @@ github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/gofuzz v1.1.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= -github.com/google/pprof v0.0.0-20241210010833-40e02aabc2ad h1:a6HEuzUHeKH6hwfN/ZoQgRgVIWFJljSWa/zetS2WTvg= -github.com/google/pprof v0.0.0-20241210010833-40e02aabc2ad/go.mod h1:vavhavw2zAxS5dIdcRluK6cSGGPlZynqzFM8NdvU144= +github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83 h1:z2ogiKUYzX5Is6zr/vP9vJGqPwcdqsWjOt+V8J7+bTc= +github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI= github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= @@ -161,7 +160,6 @@ github.com/onsi/ginkgo v0.0.0-20170829012221-11459a886d9c/go.mod h1:lLunBs/Ym6LB github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk= github.com/onsi/ginkgo v1.14.0/go.mod h1:iSB4RoI2tjJc9BBv4NKIKWKya62Rps+oPG/Lv9klQyY= -github.com/onsi/ginkgo v1.16.4 h1:29JGrr5oVBm5ulCWet69zQkzWipVXIol6ygQUe/EzNc= github.com/onsi/ginkgo v1.16.4/go.mod h1:dX+/inL/fNMqNlz0e9LfyB9TswhZpCVdJM/Z6Vvnwo0= github.com/onsi/ginkgo/v2 v2.0.0/go.mod h1:vw5CSIxN1JObi/U8gcbwft7ZxR2dgaR70JSE3/PpL4c= github.com/onsi/ginkgo/v2 v2.23.3 h1:edHxnszytJ4lD9D5Jjc4tiDkPBZ3siDeJJkUZJJVkp0= diff --git a/src/cli/internal/cmd/console/console.go b/src/cli/internal/cmd/console/console.go index 374a391a3f..0dc0de0d77 100644 --- a/src/cli/internal/cmd/console/console.go +++ b/src/cli/internal/cmd/console/console.go @@ -80,18 +80,16 @@ const ( reconnectInterval = 2 * time.Second // Interval between reconnection attempts ) +var ( + clientAndNamespaceFromContext = clientconfig.ClientAndNamespaceFromContext + connectFunc = connect +) + func (c *Console) Run(cmd *cobra.Command, args []string) error { - client, defaultNamespace, _, err := clientconfig.ClientAndNamespaceFromContext(cmd.Context()) - if err != nil { - return err - } - namespace, name, err := templates.ParseTarget(args[0]) + targetNamespace, name, err := templates.ParseTarget(args[0]) if err != nil { return err } - if namespace == "" { - namespace = defaultNamespace - } // Set terminal to raw mode once for all connections if term.IsTerminal(int(os.Stdin.Fd())) { @@ -147,7 +145,17 @@ func (c *Console) Run(cmd *cobra.Command, args []string) error { case <-doneChan: return nil default: - err := connect(cmd.Context(), name, namespace, client, c.timeout, stdinCh, doneChan) + client, defaultNamespace, _, err := clientAndNamespaceFromContext(cmd.Context()) + if err != nil { + return err + } + + namespace := targetNamespace + if namespace == "" { + namespace = defaultNamespace + } + + err = connectFunc(cmd.Context(), name, namespace, client, c.timeout, stdinCh, doneChan) if err == nil { return nil // Normal exit (escape sequence) } diff --git a/src/cli/internal/cmd/console/console_test.go b/src/cli/internal/cmd/console/console_test.go new file mode 100644 index 0000000000..bb9be57672 --- /dev/null +++ b/src/cli/internal/cmd/console/console_test.go @@ -0,0 +1,121 @@ +/* +Copyright 2026 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package console + +import ( + "context" + "errors" + "net" + "os" + "testing" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/spf13/cobra" + k8sfake "k8s.io/client-go/kubernetes/fake" + + virtualizationfake "github.com/deckhouse/virtualization/api/client/generated/clientset/versioned/fake" + virtualizationv1alpha2 "github.com/deckhouse/virtualization/api/client/generated/clientset/versioned/typed/core/v1alpha2" + "github.com/deckhouse/virtualization/api/client/kubeclient" +) + +type fakeClient struct { + *k8sfake.Clientset + virtualizationv1alpha2.VirtualizationV1alpha2Interface +} + +func newFakeClient() *fakeClient { + return &fakeClient{ + Clientset: k8sfake.NewSimpleClientset(), + VirtualizationV1alpha2Interface: virtualizationfake.NewSimpleClientset().VirtualizationV1alpha2(), + } +} + +func TestConsole(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Console Command Suite") +} + +var _ = Describe("Console", func() { + var ( + oldStdin *os.File + oldClientAndNamespaceFromContext func(context.Context) (kubeclient.Client, string, bool, error) + oldConnectFunc func(context.Context, string, string, kubeclient.Client, time.Duration, <-chan []byte, <-chan struct{}) error + ) + + BeforeEach(func() { + oldStdin = os.Stdin + oldClientAndNamespaceFromContext = clientAndNamespaceFromContext + oldConnectFunc = connectFunc + }) + + AfterEach(func() { + os.Stdin = oldStdin + clientAndNamespaceFromContext = oldClientAndNamespaceFromContext + connectFunc = oldConnectFunc + }) + + Describe("Run", func() { + It("refreshes client before reconnect", func() { + stdinReader, stdinWriter, err := os.Pipe() + Expect(err).NotTo(HaveOccurred()) + DeferCleanup(func() { + _ = stdinReader.Close() + }) + DeferCleanup(func() { + _ = stdinWriter.Close() + }) + os.Stdin = stdinReader + + var clientCalls int + clientAndNamespaceFromContext = func(context.Context) (kubeclient.Client, string, bool, error) { + clientCalls++ + return newFakeClient(), "default", false, nil + } + + var connectCalls int + connectFunc = func(_ context.Context, name, namespace string, _ kubeclient.Client, _ time.Duration, _ <-chan []byte, _ <-chan struct{}) error { + connectCalls++ + Expect(namespace).To(Equal("default")) + Expect(name).To(Equal("test-vm")) + if connectCalls == 1 { + return errors.New("temporary error") + } + return nil + } + + cmd := &cobra.Command{} + cmd.SetContext(context.Background()) + + go func() { + _ = stdinWriter.Close() + }() + + err = (&Console{timeout: time.Second}).Run(cmd, []string{"test-vm"}) + Expect(err).NotTo(HaveOccurred()) + Expect(connectCalls).To(Equal(2)) + Expect(clientCalls).To(Equal(2)) + }) + }) + + Describe("ShouldWaitErr", func() { + It("returns true for abnormal closure errors", func() { + Expect(ShouldWaitErr(&net.OpError{Err: errors.New("Internal error")})).To(BeTrue()) + }) + }) +}) diff --git a/src/cli/internal/cmd/vnc/vnc.go b/src/cli/internal/cmd/vnc/vnc.go index 49bd029ce1..6e5086bfe2 100644 --- a/src/cli/internal/cmd/vnc/vnc.go +++ b/src/cli/internal/cmd/vnc/vnc.go @@ -74,6 +74,11 @@ var ( customPort = 0 ) +var ( + clientAndNamespaceFromContext = clientconfig.ClientAndNamespaceFromContext + connectFunc = connect +) + func NewCommand() *cobra.Command { vnc := &VNC{} cmd := &cobra.Command{ @@ -94,17 +99,10 @@ func NewCommand() *cobra.Command { type VNC struct{} func (o *VNC) Run(cmd *cobra.Command, args []string) error { - client, defaultNamespace, _, err := clientconfig.ClientAndNamespaceFromContext(cmd.Context()) + targetNamespace, vmName, err := templates.ParseTarget(args[0]) if err != nil { return err } - namespace, vmName, err := templates.ParseTarget(args[0]) - if err != nil { - return err - } - if namespace == "" { - namespace = defaultNamespace - } // Format the listening address to account for the port (ex: 127.0.0.0:5900) // Set listenAddress to localhost if proxy-only flag is not set @@ -130,9 +128,19 @@ func (o *VNC) Run(cmd *cobra.Command, args []string) error { case <-cmd.Context().Done(): return nil default: + client, defaultNamespace, _, err := clientAndNamespaceFromContext(cmd.Context()) + if err != nil { + return err + } + + namespace := targetNamespace + if namespace == "" { + namespace = defaultNamespace + } + cmd.Printf("Connecting to %s VNC...\n", vmName) - err := connect(cmd.Context(), ln, client, cmd, namespace, vmName) + err = connectFunc(cmd.Context(), ln, client, cmd, namespace, vmName) if err != nil { if strings.Contains(err.Error(), "not found") { return err diff --git a/src/cli/internal/cmd/vnc/vnc_test.go b/src/cli/internal/cmd/vnc/vnc_test.go new file mode 100644 index 0000000000..06c8a3a663 --- /dev/null +++ b/src/cli/internal/cmd/vnc/vnc_test.go @@ -0,0 +1,114 @@ +/* +Copyright 2026 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package vnc + +import ( + "bytes" + "context" + "errors" + "net" + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/spf13/cobra" + k8sfake "k8s.io/client-go/kubernetes/fake" + + virtualizationfake "github.com/deckhouse/virtualization/api/client/generated/clientset/versioned/fake" + virtualizationv1alpha2 "github.com/deckhouse/virtualization/api/client/generated/clientset/versioned/typed/core/v1alpha2" + "github.com/deckhouse/virtualization/api/client/kubeclient" +) + +type fakeClient struct { + *k8sfake.Clientset + virtualizationv1alpha2.VirtualizationV1alpha2Interface +} + +func newFakeClient() *fakeClient { + return &fakeClient{ + Clientset: k8sfake.NewSimpleClientset(), + VirtualizationV1alpha2Interface: virtualizationfake.NewSimpleClientset().VirtualizationV1alpha2(), + } +} + +func TestVNC(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "VNC Command Suite") +} + +var _ = Describe("VNC", func() { + var ( + oldProxyOnly bool + oldCustomPort int + oldListenAddress string + oldClientAndNamespaceFromContext func(context.Context) (kubeclient.Client, string, bool, error) + oldConnectFunc func(context.Context, *net.TCPListener, kubeclient.Client, *cobra.Command, string, string) error + ) + + BeforeEach(func() { + oldProxyOnly = proxyOnly + oldCustomPort = customPort + oldListenAddress = listenAddress + oldClientAndNamespaceFromContext = clientAndNamespaceFromContext + oldConnectFunc = connectFunc + }) + + AfterEach(func() { + proxyOnly = oldProxyOnly + customPort = oldCustomPort + listenAddress = oldListenAddress + clientAndNamespaceFromContext = oldClientAndNamespaceFromContext + connectFunc = oldConnectFunc + }) + + Describe("Run", func() { + It("refreshes client before reconnect", func() { + proxyOnly = true + customPort = 0 + listenAddress = "127.0.0.1" + + var clientCalls int + clientAndNamespaceFromContext = func(context.Context) (kubeclient.Client, string, bool, error) { + clientCalls++ + return newFakeClient(), "default", false, nil + } + + var connectCalls int + connectFunc = func(_ context.Context, ln *net.TCPListener, _ kubeclient.Client, _ *cobra.Command, namespace, vmName string) error { + connectCalls++ + Expect(ln).NotTo(BeNil()) + Expect(namespace).To(Equal("default")) + Expect(vmName).To(Equal("test-vm")) + if connectCalls == 1 { + return errors.New("temporary error") + } + return nil + } + + cmd := &cobra.Command{} + stdout := &bytes.Buffer{} + cmd.SetOut(stdout) + cmd.SetErr(stdout) + cmd.SetContext(context.Background()) + + err := (&VNC{}).Run(cmd, []string{"test-vm"}) + Expect(err).NotTo(HaveOccurred()) + Expect(connectCalls).To(Equal(2)) + Expect(clientCalls).To(Equal(2)) + }) + }) +})