Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions src/cli/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
6 changes: 2 additions & 4 deletions src/cli/go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down Expand Up @@ -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=
Expand Down Expand Up @@ -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=
Expand Down
26 changes: 17 additions & 9 deletions src/cli/internal/cmd/console/console.go
Original file line number Diff line number Diff line change
Expand Up @@ -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())) {
Expand Down Expand Up @@ -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)
}
Expand Down
121 changes: 121 additions & 0 deletions src/cli/internal/cmd/console/console_test.go
Original file line number Diff line number Diff line change
@@ -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())
})
})
})
26 changes: 17 additions & 9 deletions src/cli/internal/cmd/vnc/vnc.go
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,11 @@ var (
customPort = 0
)

var (
clientAndNamespaceFromContext = clientconfig.ClientAndNamespaceFromContext
connectFunc = connect
)

func NewCommand() *cobra.Command {
vnc := &VNC{}
cmd := &cobra.Command{
Expand All @@ -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
Expand All @@ -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
Expand Down
114 changes: 114 additions & 0 deletions src/cli/internal/cmd/vnc/vnc_test.go
Original file line number Diff line number Diff line change
@@ -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))
})
})
})
Loading