Skip to content

Commit 3181e16

Browse files
authored
fix(cli): refresh token lost on reconnect (#2253)
Refresh CLI client config before reconnecting long-lived VM access sessions. This PR updates d8 v vnc and d8 v console so they recreate the Kubernetes client from the current context before each reconnect attempt instead of reusing the client created at command start. Also adds unit tests covering reconnect behavior for both commands. --------- Signed-off-by: Daniil Antoshin <daniil.antoshin@flant.com>
1 parent 090d36f commit 3181e16

6 files changed

Lines changed: 276 additions & 22 deletions

File tree

src/cli/go.mod

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ require (
66
github.com/deckhouse/virtualization/api v0.15.0
77
github.com/fatih/color v1.18.0
88
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674
9+
github.com/onsi/ginkgo/v2 v2.23.3
10+
github.com/onsi/gomega v1.37.0
911
github.com/povsister/scp v0.0.0-20250504051308-e467f71ea63c
1012
github.com/spf13/cobra v1.9.1
1113
github.com/spf13/pflag v1.0.7
@@ -34,9 +36,11 @@ require (
3436
github.com/go-openapi/jsonpointer v0.21.0 // indirect
3537
github.com/go-openapi/jsonreference v0.21.0 // indirect
3638
github.com/go-openapi/swag v0.23.0 // indirect
39+
github.com/go-task/slim-sprig/v3 v3.0.0 // indirect
3740
github.com/gogo/protobuf v1.3.2 // indirect
3841
github.com/google/gnostic-models v0.7.0 // indirect
3942
github.com/google/go-cmp v0.7.0 // indirect
43+
github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83 // indirect
4044
github.com/google/uuid v1.6.0 // indirect
4145
github.com/inconshreveable/mousetrap v1.1.0 // indirect
4246
github.com/josharian/intern v1.0.0 // indirect
@@ -64,6 +68,7 @@ require (
6468
golang.org/x/net v0.47.0 // indirect
6569
golang.org/x/oauth2 v0.27.0 // indirect
6670
golang.org/x/time v0.9.0 // indirect
71+
golang.org/x/tools v0.38.0 // indirect
6772
google.golang.org/protobuf v1.36.5 // indirect
6873
gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect
6974
gopkg.in/inf.v0 v0.9.1 // indirect

src/cli/go.sum

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,6 @@ github.com/go-openapi/swag v0.19.14/go.mod h1:QYRuS/SOXUCsnplDa677K7+DxSOj6IPNl/
6363
github.com/go-openapi/swag v0.21.1/go.mod h1:QYRuS/SOXUCsnplDa677K7+DxSOj6IPNl/eQntq43wQ=
6464
github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE=
6565
github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ=
66-
github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0 h1:p104kn46Q8WdvHunIJ9dAyjPVtrBPhSr3KT2yUst43I=
6766
github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE=
6867
github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
6968
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
9493
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
9594
github.com/google/gofuzz v1.1.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
9695
github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
97-
github.com/google/pprof v0.0.0-20241210010833-40e02aabc2ad h1:a6HEuzUHeKH6hwfN/ZoQgRgVIWFJljSWa/zetS2WTvg=
98-
github.com/google/pprof v0.0.0-20241210010833-40e02aabc2ad/go.mod h1:vavhavw2zAxS5dIdcRluK6cSGGPlZynqzFM8NdvU144=
96+
github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83 h1:z2ogiKUYzX5Is6zr/vP9vJGqPwcdqsWjOt+V8J7+bTc=
97+
github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI=
9998
github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
10099
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
101100
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
161160
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
162161
github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk=
163162
github.com/onsi/ginkgo v1.14.0/go.mod h1:iSB4RoI2tjJc9BBv4NKIKWKya62Rps+oPG/Lv9klQyY=
164-
github.com/onsi/ginkgo v1.16.4 h1:29JGrr5oVBm5ulCWet69zQkzWipVXIol6ygQUe/EzNc=
165163
github.com/onsi/ginkgo v1.16.4/go.mod h1:dX+/inL/fNMqNlz0e9LfyB9TswhZpCVdJM/Z6Vvnwo0=
166164
github.com/onsi/ginkgo/v2 v2.0.0/go.mod h1:vw5CSIxN1JObi/U8gcbwft7ZxR2dgaR70JSE3/PpL4c=
167165
github.com/onsi/ginkgo/v2 v2.23.3 h1:edHxnszytJ4lD9D5Jjc4tiDkPBZ3siDeJJkUZJJVkp0=

src/cli/internal/cmd/console/console.go

Lines changed: 17 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -80,18 +80,16 @@ const (
8080
reconnectInterval = 2 * time.Second // Interval between reconnection attempts
8181
)
8282

83+
var (
84+
clientAndNamespaceFromContext = clientconfig.ClientAndNamespaceFromContext
85+
connectFunc = connect
86+
)
87+
8388
func (c *Console) Run(cmd *cobra.Command, args []string) error {
84-
client, defaultNamespace, _, err := clientconfig.ClientAndNamespaceFromContext(cmd.Context())
85-
if err != nil {
86-
return err
87-
}
88-
namespace, name, err := templates.ParseTarget(args[0])
89+
targetNamespace, name, err := templates.ParseTarget(args[0])
8990
if err != nil {
9091
return err
9192
}
92-
if namespace == "" {
93-
namespace = defaultNamespace
94-
}
9593

9694
// Set terminal to raw mode once for all connections
9795
if term.IsTerminal(int(os.Stdin.Fd())) {
@@ -147,7 +145,17 @@ func (c *Console) Run(cmd *cobra.Command, args []string) error {
147145
case <-doneChan:
148146
return nil
149147
default:
150-
err := connect(cmd.Context(), name, namespace, client, c.timeout, stdinCh, doneChan)
148+
client, defaultNamespace, _, err := clientAndNamespaceFromContext(cmd.Context())
149+
if err != nil {
150+
return err
151+
}
152+
153+
namespace := targetNamespace
154+
if namespace == "" {
155+
namespace = defaultNamespace
156+
}
157+
158+
err = connectFunc(cmd.Context(), name, namespace, client, c.timeout, stdinCh, doneChan)
151159
if err == nil {
152160
return nil // Normal exit (escape sequence)
153161
}
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
/*
2+
Copyright 2026 Flant JSC
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package console
18+
19+
import (
20+
"context"
21+
"errors"
22+
"net"
23+
"os"
24+
"testing"
25+
"time"
26+
27+
. "github.com/onsi/ginkgo/v2"
28+
. "github.com/onsi/gomega"
29+
"github.com/spf13/cobra"
30+
k8sfake "k8s.io/client-go/kubernetes/fake"
31+
32+
virtualizationfake "github.com/deckhouse/virtualization/api/client/generated/clientset/versioned/fake"
33+
virtualizationv1alpha2 "github.com/deckhouse/virtualization/api/client/generated/clientset/versioned/typed/core/v1alpha2"
34+
"github.com/deckhouse/virtualization/api/client/kubeclient"
35+
)
36+
37+
type fakeClient struct {
38+
*k8sfake.Clientset
39+
virtualizationv1alpha2.VirtualizationV1alpha2Interface
40+
}
41+
42+
func newFakeClient() *fakeClient {
43+
return &fakeClient{
44+
Clientset: k8sfake.NewSimpleClientset(),
45+
VirtualizationV1alpha2Interface: virtualizationfake.NewSimpleClientset().VirtualizationV1alpha2(),
46+
}
47+
}
48+
49+
func TestConsole(t *testing.T) {
50+
RegisterFailHandler(Fail)
51+
RunSpecs(t, "Console Command Suite")
52+
}
53+
54+
var _ = Describe("Console", func() {
55+
var (
56+
oldStdin *os.File
57+
oldClientAndNamespaceFromContext func(context.Context) (kubeclient.Client, string, bool, error)
58+
oldConnectFunc func(context.Context, string, string, kubeclient.Client, time.Duration, <-chan []byte, <-chan struct{}) error
59+
)
60+
61+
BeforeEach(func() {
62+
oldStdin = os.Stdin
63+
oldClientAndNamespaceFromContext = clientAndNamespaceFromContext
64+
oldConnectFunc = connectFunc
65+
})
66+
67+
AfterEach(func() {
68+
os.Stdin = oldStdin
69+
clientAndNamespaceFromContext = oldClientAndNamespaceFromContext
70+
connectFunc = oldConnectFunc
71+
})
72+
73+
Describe("Run", func() {
74+
It("refreshes client before reconnect", func() {
75+
stdinReader, stdinWriter, err := os.Pipe()
76+
Expect(err).NotTo(HaveOccurred())
77+
DeferCleanup(func() {
78+
_ = stdinReader.Close()
79+
})
80+
DeferCleanup(func() {
81+
_ = stdinWriter.Close()
82+
})
83+
os.Stdin = stdinReader
84+
85+
var clientCalls int
86+
clientAndNamespaceFromContext = func(context.Context) (kubeclient.Client, string, bool, error) {
87+
clientCalls++
88+
return newFakeClient(), "default", false, nil
89+
}
90+
91+
var connectCalls int
92+
connectFunc = func(_ context.Context, name, namespace string, _ kubeclient.Client, _ time.Duration, _ <-chan []byte, _ <-chan struct{}) error {
93+
connectCalls++
94+
Expect(namespace).To(Equal("default"))
95+
Expect(name).To(Equal("test-vm"))
96+
if connectCalls == 1 {
97+
return errors.New("temporary error")
98+
}
99+
return nil
100+
}
101+
102+
cmd := &cobra.Command{}
103+
cmd.SetContext(context.Background())
104+
105+
go func() {
106+
_ = stdinWriter.Close()
107+
}()
108+
109+
err = (&Console{timeout: time.Second}).Run(cmd, []string{"test-vm"})
110+
Expect(err).NotTo(HaveOccurred())
111+
Expect(connectCalls).To(Equal(2))
112+
Expect(clientCalls).To(Equal(2))
113+
})
114+
})
115+
116+
Describe("ShouldWaitErr", func() {
117+
It("returns true for abnormal closure errors", func() {
118+
Expect(ShouldWaitErr(&net.OpError{Err: errors.New("Internal error")})).To(BeTrue())
119+
})
120+
})
121+
})

src/cli/internal/cmd/vnc/vnc.go

Lines changed: 17 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,11 @@ var (
7474
customPort = 0
7575
)
7676

77+
var (
78+
clientAndNamespaceFromContext = clientconfig.ClientAndNamespaceFromContext
79+
connectFunc = connect
80+
)
81+
7782
func NewCommand() *cobra.Command {
7883
vnc := &VNC{}
7984
cmd := &cobra.Command{
@@ -94,17 +99,10 @@ func NewCommand() *cobra.Command {
9499
type VNC struct{}
95100

96101
func (o *VNC) Run(cmd *cobra.Command, args []string) error {
97-
client, defaultNamespace, _, err := clientconfig.ClientAndNamespaceFromContext(cmd.Context())
102+
targetNamespace, vmName, err := templates.ParseTarget(args[0])
98103
if err != nil {
99104
return err
100105
}
101-
namespace, vmName, err := templates.ParseTarget(args[0])
102-
if err != nil {
103-
return err
104-
}
105-
if namespace == "" {
106-
namespace = defaultNamespace
107-
}
108106

109107
// Format the listening address to account for the port (ex: 127.0.0.0:5900)
110108
// 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 {
130128
case <-cmd.Context().Done():
131129
return nil
132130
default:
131+
client, defaultNamespace, _, err := clientAndNamespaceFromContext(cmd.Context())
132+
if err != nil {
133+
return err
134+
}
135+
136+
namespace := targetNamespace
137+
if namespace == "" {
138+
namespace = defaultNamespace
139+
}
140+
133141
cmd.Printf("Connecting to %s VNC...\n", vmName)
134142

135-
err := connect(cmd.Context(), ln, client, cmd, namespace, vmName)
143+
err = connectFunc(cmd.Context(), ln, client, cmd, namespace, vmName)
136144
if err != nil {
137145
if strings.Contains(err.Error(), "not found") {
138146
return err
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
/*
2+
Copyright 2026 Flant JSC
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package vnc
18+
19+
import (
20+
"bytes"
21+
"context"
22+
"errors"
23+
"net"
24+
"testing"
25+
26+
. "github.com/onsi/ginkgo/v2"
27+
. "github.com/onsi/gomega"
28+
"github.com/spf13/cobra"
29+
k8sfake "k8s.io/client-go/kubernetes/fake"
30+
31+
virtualizationfake "github.com/deckhouse/virtualization/api/client/generated/clientset/versioned/fake"
32+
virtualizationv1alpha2 "github.com/deckhouse/virtualization/api/client/generated/clientset/versioned/typed/core/v1alpha2"
33+
"github.com/deckhouse/virtualization/api/client/kubeclient"
34+
)
35+
36+
type fakeClient struct {
37+
*k8sfake.Clientset
38+
virtualizationv1alpha2.VirtualizationV1alpha2Interface
39+
}
40+
41+
func newFakeClient() *fakeClient {
42+
return &fakeClient{
43+
Clientset: k8sfake.NewSimpleClientset(),
44+
VirtualizationV1alpha2Interface: virtualizationfake.NewSimpleClientset().VirtualizationV1alpha2(),
45+
}
46+
}
47+
48+
func TestVNC(t *testing.T) {
49+
RegisterFailHandler(Fail)
50+
RunSpecs(t, "VNC Command Suite")
51+
}
52+
53+
var _ = Describe("VNC", func() {
54+
var (
55+
oldProxyOnly bool
56+
oldCustomPort int
57+
oldListenAddress string
58+
oldClientAndNamespaceFromContext func(context.Context) (kubeclient.Client, string, bool, error)
59+
oldConnectFunc func(context.Context, *net.TCPListener, kubeclient.Client, *cobra.Command, string, string) error
60+
)
61+
62+
BeforeEach(func() {
63+
oldProxyOnly = proxyOnly
64+
oldCustomPort = customPort
65+
oldListenAddress = listenAddress
66+
oldClientAndNamespaceFromContext = clientAndNamespaceFromContext
67+
oldConnectFunc = connectFunc
68+
})
69+
70+
AfterEach(func() {
71+
proxyOnly = oldProxyOnly
72+
customPort = oldCustomPort
73+
listenAddress = oldListenAddress
74+
clientAndNamespaceFromContext = oldClientAndNamespaceFromContext
75+
connectFunc = oldConnectFunc
76+
})
77+
78+
Describe("Run", func() {
79+
It("refreshes client before reconnect", func() {
80+
proxyOnly = true
81+
customPort = 0
82+
listenAddress = "127.0.0.1"
83+
84+
var clientCalls int
85+
clientAndNamespaceFromContext = func(context.Context) (kubeclient.Client, string, bool, error) {
86+
clientCalls++
87+
return newFakeClient(), "default", false, nil
88+
}
89+
90+
var connectCalls int
91+
connectFunc = func(_ context.Context, ln *net.TCPListener, _ kubeclient.Client, _ *cobra.Command, namespace, vmName string) error {
92+
connectCalls++
93+
Expect(ln).NotTo(BeNil())
94+
Expect(namespace).To(Equal("default"))
95+
Expect(vmName).To(Equal("test-vm"))
96+
if connectCalls == 1 {
97+
return errors.New("temporary error")
98+
}
99+
return nil
100+
}
101+
102+
cmd := &cobra.Command{}
103+
stdout := &bytes.Buffer{}
104+
cmd.SetOut(stdout)
105+
cmd.SetErr(stdout)
106+
cmd.SetContext(context.Background())
107+
108+
err := (&VNC{}).Run(cmd, []string{"test-vm"})
109+
Expect(err).NotTo(HaveOccurred())
110+
Expect(connectCalls).To(Equal(2))
111+
Expect(clientCalls).To(Equal(2))
112+
})
113+
})
114+
})

0 commit comments

Comments
 (0)