Movatterモバイル変換


[0]ホーム

URL:


Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Sign up
Appearance settings

Commitc5a265f

Browse files
authored
feat(cli): add experimental rpty command (#16700)
Relates to#16419Builds upon#16638 and adds a command`exp rpty` that allows you to open a ReconnectingPTY session to anagent.This ultimately allows us to add an integration-style CLI test to verifythe functionality added in#16638 .
1 parent38c0e8a commitc5a265f

File tree

7 files changed

+333
-0
lines changed

7 files changed

+333
-0
lines changed

‎cli/dotfiles_test.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,10 @@ import (
1717

1818
funcTestDotfiles(t*testing.T) {
1919
t.Parallel()
20+
// This test will time out if the user has commit signing enabled.
21+
if_,gpgTTYFound:=os.LookupEnv("GPG_TTY");gpgTTYFound {
22+
t.Skip("GPG_TTY is set, skipping test to avoid hanging")
23+
}
2024
t.Run("MissingArg",func(t*testing.T) {
2125
t.Parallel()
2226
inv,_:=clitest.New(t,"dotfiles")

‎cli/exp.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ func (r *RootCmd) expCmd() *serpent.Command {
1414
r.scaletestCmd(),
1515
r.errorExample(),
1616
r.promptExample(),
17+
r.rptyCommand(),
1718
},
1819
}
1920
returncmd
File renamed without changes.
File renamed without changes.
File renamed without changes.

‎cli/exp_rpty.go

Lines changed: 216 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,216 @@
1+
package cli
2+
3+
import (
4+
"bufio"
5+
"context"
6+
"encoding/json"
7+
"fmt"
8+
"io"
9+
"os"
10+
"strings"
11+
12+
"github.com/google/uuid"
13+
"github.com/mattn/go-isatty"
14+
"golang.org/x/term"
15+
"golang.org/x/xerrors"
16+
17+
"github.com/coder/coder/v2/cli/cliui"
18+
"github.com/coder/coder/v2/codersdk"
19+
"github.com/coder/coder/v2/codersdk/workspacesdk"
20+
"github.com/coder/coder/v2/pty"
21+
"github.com/coder/serpent"
22+
)
23+
24+
func (r*RootCmd)rptyCommand()*serpent.Command {
25+
var (
26+
client=new(codersdk.Client)
27+
argshandleRPTYArgs
28+
)
29+
30+
cmd:=&serpent.Command{
31+
Handler:func(inv*serpent.Invocation)error {
32+
ifr.disableDirect {
33+
returnxerrors.New("direct connections are disabled, but you can try websocat ;-)")
34+
}
35+
args.NamedWorkspace=inv.Args[0]
36+
args.Command=inv.Args[1:]
37+
returnhandleRPTY(inv,client,args)
38+
},
39+
Long:"Establish an RPTY session with a workspace/agent. This uses the same mechanism as the Web Terminal.",
40+
Middleware:serpent.Chain(
41+
serpent.RequireRangeArgs(1,-1),
42+
r.InitClient(client),
43+
),
44+
Options: []serpent.Option{
45+
{
46+
Name:"container",
47+
Description:"The container name or ID to connect to.",
48+
Flag:"container",
49+
FlagShorthand:"c",
50+
Default:"",
51+
Value:serpent.StringOf(&args.Container),
52+
},
53+
{
54+
Name:"container-user",
55+
Description:"The user to connect as.",
56+
Flag:"container-user",
57+
FlagShorthand:"u",
58+
Default:"",
59+
Value:serpent.StringOf(&args.ContainerUser),
60+
},
61+
{
62+
Name:"reconnect",
63+
Description:"The reconnect ID to use.",
64+
Flag:"reconnect",
65+
FlagShorthand:"r",
66+
Default:"",
67+
Value:serpent.StringOf(&args.ReconnectID),
68+
},
69+
},
70+
Short:"Establish an RPTY session with a workspace/agent.",
71+
Use:"rpty",
72+
}
73+
74+
returncmd
75+
}
76+
77+
typehandleRPTYArgsstruct {
78+
Command []string
79+
Containerstring
80+
ContainerUserstring
81+
NamedWorkspacestring
82+
ReconnectIDstring
83+
}
84+
85+
funchandleRPTY(inv*serpent.Invocation,client*codersdk.Client,argshandleRPTYArgs)error {
86+
ctx,cancel:=context.WithCancel(inv.Context())
87+
defercancel()
88+
89+
varreconnectID uuid.UUID
90+
ifargs.ReconnectID!="" {
91+
rid,err:=uuid.Parse(args.ReconnectID)
92+
iferr!=nil {
93+
returnxerrors.Errorf("invalid reconnect ID: %w",err)
94+
}
95+
reconnectID=rid
96+
}else {
97+
reconnectID=uuid.New()
98+
}
99+
ws,agt,err:=getWorkspaceAndAgent(ctx,inv,client,true,args.NamedWorkspace)
100+
iferr!=nil {
101+
returnerr
102+
}
103+
104+
varctIDstring
105+
ifargs.Container!="" {
106+
cts,err:=client.WorkspaceAgentListContainers(ctx,agt.ID,nil)
107+
iferr!=nil {
108+
returnerr
109+
}
110+
for_,ct:=rangects.Containers {
111+
ifct.FriendlyName==args.Container||ct.ID==args.Container {
112+
ctID=ct.ID
113+
break
114+
}
115+
}
116+
ifctID=="" {
117+
returnxerrors.Errorf("container %q not found",args.Container)
118+
}
119+
}
120+
121+
iferr:=cliui.Agent(ctx,inv.Stderr,agt.ID, cliui.AgentOptions{
122+
FetchInterval:0,
123+
Fetch:client.WorkspaceAgent,
124+
Wait:false,
125+
});err!=nil {
126+
returnerr
127+
}
128+
129+
// Get the width and height of the terminal.
130+
vartermWidth,termHeightuint16
131+
stdoutFile,validOut:=inv.Stdout.(*os.File)
132+
ifvalidOut&&isatty.IsTerminal(stdoutFile.Fd()) {
133+
w,h,err:=term.GetSize(int(stdoutFile.Fd()))
134+
iferr==nil {
135+
//nolint: gosec
136+
termWidth,termHeight=uint16(w),uint16(h)
137+
}
138+
}
139+
140+
// Set stdin to raw mode so that control characters work.
141+
stdinFile,validIn:=inv.Stdin.(*os.File)
142+
ifvalidIn&&isatty.IsTerminal(stdinFile.Fd()) {
143+
inState,err:=pty.MakeInputRaw(stdinFile.Fd())
144+
iferr!=nil {
145+
returnxerrors.Errorf("failed to set input terminal to raw mode: %w",err)
146+
}
147+
deferfunc() {
148+
_=pty.RestoreTerminal(stdinFile.Fd(),inState)
149+
}()
150+
}
151+
152+
conn,err:=workspacesdk.New(client).AgentReconnectingPTY(ctx, workspacesdk.WorkspaceAgentReconnectingPTYOpts{
153+
AgentID:agt.ID,
154+
Reconnect:reconnectID,
155+
Command:strings.Join(args.Command," "),
156+
Container:ctID,
157+
ContainerUser:args.ContainerUser,
158+
Width:termWidth,
159+
Height:termHeight,
160+
})
161+
iferr!=nil {
162+
returnxerrors.Errorf("open reconnecting PTY: %w",err)
163+
}
164+
deferconn.Close()
165+
166+
cliui.Infof(inv.Stderr,"Connected to %s (agent id: %s)",args.NamedWorkspace,agt.ID)
167+
cliui.Infof(inv.Stderr,"Reconnect ID: %s",reconnectID)
168+
closeUsage:=client.UpdateWorkspaceUsageWithBodyContext(ctx,ws.ID, codersdk.PostWorkspaceUsageRequest{
169+
AgentID:agt.ID,
170+
AppName:codersdk.UsageAppNameReconnectingPty,
171+
})
172+
defercloseUsage()
173+
174+
br:=bufio.NewScanner(inv.Stdin)
175+
// Split on bytes, otherwise you have to send a newline to flush the buffer.
176+
br.Split(bufio.ScanBytes)
177+
je:=json.NewEncoder(conn)
178+
179+
gofunc() {
180+
forbr.Scan() {
181+
iferr:=je.Encode(map[string]string{
182+
"data":br.Text(),
183+
});err!=nil {
184+
return
185+
}
186+
}
187+
}()
188+
189+
windowChange:=listenWindowSize(ctx)
190+
gofunc() {
191+
for {
192+
select {
193+
case<-ctx.Done():
194+
return
195+
case<-windowChange:
196+
}
197+
width,height,err:=term.GetSize(int(stdoutFile.Fd()))
198+
iferr!=nil {
199+
continue
200+
}
201+
iferr:=je.Encode(map[string]int{
202+
"width":width,
203+
"height":height,
204+
});err!=nil {
205+
cliui.Errorf(inv.Stderr,"Failed to send window size: %v",err)
206+
}
207+
}
208+
}()
209+
210+
_,_=io.Copy(inv.Stdout,conn)
211+
cancel()
212+
_=conn.Close()
213+
_,_=fmt.Fprintf(inv.Stderr,"Connection closed\n")
214+
215+
returnnil
216+
}

‎cli/exp_rpty_test.go

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
package cli_test
2+
3+
import (
4+
"fmt"
5+
"runtime"
6+
"testing"
7+
8+
"github.com/ory/dockertest/v3"
9+
"github.com/ory/dockertest/v3/docker"
10+
11+
"github.com/coder/coder/v2/agent"
12+
"github.com/coder/coder/v2/agent/agenttest"
13+
"github.com/coder/coder/v2/cli/clitest"
14+
"github.com/coder/coder/v2/coderd/coderdtest"
15+
"github.com/coder/coder/v2/pty/ptytest"
16+
"github.com/coder/coder/v2/testutil"
17+
18+
"github.com/stretchr/testify/assert"
19+
"github.com/stretchr/testify/require"
20+
)
21+
22+
funcTestExpRpty(t*testing.T) {
23+
t.Parallel()
24+
25+
t.Run("OK",func(t*testing.T) {
26+
t.Parallel()
27+
28+
client,workspace,agentToken:=setupWorkspaceForAgent(t)
29+
inv,root:=clitest.New(t,"exp","rpty",workspace.Name)
30+
clitest.SetupConfig(t,client,root)
31+
pty:=ptytest.New(t).Attach(inv)
32+
33+
ctx:=testutil.Context(t,testutil.WaitLong)
34+
35+
cmdDone:=tGo(t,func() {
36+
err:=inv.WithContext(ctx).Run()
37+
assert.NoError(t,err)
38+
})
39+
40+
_=agenttest.New(t,client.URL,agentToken)
41+
_=coderdtest.NewWorkspaceAgentWaiter(t,client,workspace.ID).Wait()
42+
43+
pty.ExpectMatch(fmt.Sprintf("Connected to %s",workspace.Name))
44+
pty.WriteLine("exit")
45+
<-cmdDone
46+
})
47+
48+
t.Run("NotFound",func(t*testing.T) {
49+
t.Parallel()
50+
51+
client,_,_:=setupWorkspaceForAgent(t)
52+
inv,root:=clitest.New(t,"exp","rpty","not-found")
53+
clitest.SetupConfig(t,client,root)
54+
55+
ctx:=testutil.Context(t,testutil.WaitShort)
56+
err:=inv.WithContext(ctx).Run()
57+
require.ErrorContains(t,err,"not found")
58+
})
59+
60+
t.Run("Container",func(t*testing.T) {
61+
t.Parallel()
62+
// Skip this test on non-Linux platforms since it requires Docker
63+
ifruntime.GOOS!="linux" {
64+
t.Skip("Skipping test on non-Linux platform")
65+
}
66+
67+
client,workspace,agentToken:=setupWorkspaceForAgent(t)
68+
ctx:=testutil.Context(t,testutil.WaitLong)
69+
pool,err:=dockertest.NewPool("")
70+
require.NoError(t,err,"Could not connect to docker")
71+
ct,err:=pool.RunWithOptions(&dockertest.RunOptions{
72+
Repository:"busybox",
73+
Tag:"latest",
74+
Cmd: []string{"sleep","infnity"},
75+
},func(config*docker.HostConfig) {
76+
config.AutoRemove=true
77+
config.RestartPolicy= docker.RestartPolicy{Name:"no"}
78+
})
79+
require.NoError(t,err,"Could not start container")
80+
// Wait for container to start
81+
require.Eventually(t,func()bool {
82+
ct,ok:=pool.ContainerByName(ct.Container.Name)
83+
returnok&&ct.Container.State.Running
84+
},testutil.WaitShort,testutil.IntervalSlow,"Container did not start in time")
85+
t.Cleanup(func() {
86+
err:=pool.Purge(ct)
87+
require.NoError(t,err,"Could not stop container")
88+
})
89+
90+
inv,root:=clitest.New(t,"exp","rpty",workspace.Name,"-c",ct.Container.ID)
91+
clitest.SetupConfig(t,client,root)
92+
pty:=ptytest.New(t).Attach(inv)
93+
94+
cmdDone:=tGo(t,func() {
95+
err:=inv.WithContext(ctx).Run()
96+
assert.NoError(t,err)
97+
})
98+
99+
_=agenttest.New(t,client.URL,agentToken,func(o*agent.Options) {
100+
o.ExperimentalContainersEnabled=true
101+
})
102+
_=coderdtest.NewWorkspaceAgentWaiter(t,client,workspace.ID).Wait()
103+
104+
pty.ExpectMatch(fmt.Sprintf("Connected to %s",workspace.Name))
105+
pty.ExpectMatch("Reconnect ID: ")
106+
pty.ExpectMatch(" #")
107+
pty.WriteLine("hostname")
108+
pty.ExpectMatch(ct.Container.Config.Hostname)
109+
pty.WriteLine("exit")
110+
<-cmdDone
111+
})
112+
}

0 commit comments

Comments
 (0)

[8]ページ先頭

©2009-2025 Movatter.jp