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

Commit0474888

Browse files
authored
feat(cli): add open app <workspace> <app-slug> command (#17032)
Fixes#17009Adds a CLI command `coder open app <workspace> <app-slug>` that allowsopening arbitrary `coder_apps` via the CLI.Users can optionally specify a region for workspaceapplications.
1 parent3b6bee9 commit0474888

File tree

8 files changed

+431
-3
lines changed

8 files changed

+431
-3
lines changed

‎cli/open.go

Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,14 @@ package cli
22

33
import (
44
"context"
5+
"errors"
56
"fmt"
7+
"net/http"
68
"net/url"
79
"path"
810
"path/filepath"
911
"runtime"
12+
"slices"
1013
"strings"
1114

1215
"github.com/skratchdot/open-golang/open"
@@ -26,6 +29,7 @@ func (r *RootCmd) open() *serpent.Command {
2629
},
2730
Children: []*serpent.Command{
2831
r.openVSCode(),
32+
r.openApp(),
2933
},
3034
}
3135
returncmd
@@ -211,6 +215,131 @@ func (r *RootCmd) openVSCode() *serpent.Command {
211215
returncmd
212216
}
213217

218+
func (r*RootCmd)openApp()*serpent.Command {
219+
var (
220+
regionArgstring
221+
testOpenErrorbool
222+
)
223+
224+
client:=new(codersdk.Client)
225+
cmd:=&serpent.Command{
226+
Annotations:workspaceCommand,
227+
Use:"app <workspace> <app slug>",
228+
Short:"Open a workspace application.",
229+
Middleware:serpent.Chain(
230+
r.InitClient(client),
231+
),
232+
Handler:func(inv*serpent.Invocation)error {
233+
ctx,cancel:=context.WithCancel(inv.Context())
234+
defercancel()
235+
236+
iflen(inv.Args)==0||len(inv.Args)>2 {
237+
returninv.Command.HelpHandler(inv)
238+
}
239+
240+
workspaceName:=inv.Args[0]
241+
ws,agt,err:=getWorkspaceAndAgent(ctx,inv,client,false,workspaceName)
242+
iferr!=nil {
243+
varsdkErr*codersdk.Error
244+
iferrors.As(err,&sdkErr)&&sdkErr.StatusCode()==http.StatusNotFound {
245+
cliui.Errorf(inv.Stderr,"Workspace %q not found!",workspaceName)
246+
returnsdkErr
247+
}
248+
cliui.Errorf(inv.Stderr,"Failed to get workspace and agent: %s",err)
249+
returnerr
250+
}
251+
252+
allAppSlugs:=make([]string,len(agt.Apps))
253+
fori,app:=rangeagt.Apps {
254+
allAppSlugs[i]=app.Slug
255+
}
256+
slices.Sort(allAppSlugs)
257+
258+
// If a user doesn't specify an app slug, we'll just list the available
259+
// apps and exit.
260+
iflen(inv.Args)==1 {
261+
cliui.Infof(inv.Stderr,"Available apps in %q: %v",workspaceName,allAppSlugs)
262+
returnnil
263+
}
264+
265+
appSlug:=inv.Args[1]
266+
varfoundApp codersdk.WorkspaceApp
267+
appIdx:=slices.IndexFunc(agt.Apps,func(a codersdk.WorkspaceApp)bool {
268+
returna.Slug==appSlug
269+
})
270+
ifappIdx==-1 {
271+
cliui.Errorf(inv.Stderr,"App %q not found in workspace %q!\nAvailable apps: %v",appSlug,workspaceName,allAppSlugs)
272+
returnxerrors.Errorf("app not found")
273+
}
274+
foundApp=agt.Apps[appIdx]
275+
276+
// To build the app URL, we need to know the wildcard hostname
277+
// and path app URL for the region.
278+
regions,err:=client.Regions(ctx)
279+
iferr!=nil {
280+
returnxerrors.Errorf("failed to fetch regions: %w",err)
281+
}
282+
varregion codersdk.Region
283+
preferredIdx:=slices.IndexFunc(regions,func(r codersdk.Region)bool {
284+
returnr.Name==regionArg
285+
})
286+
ifpreferredIdx==-1 {
287+
allRegions:=make([]string,len(regions))
288+
fori,r:=rangeregions {
289+
allRegions[i]=r.Name
290+
}
291+
cliui.Errorf(inv.Stderr,"Preferred region %q not found!\nAvailable regions: %v",regionArg,allRegions)
292+
returnxerrors.Errorf("region not found")
293+
}
294+
region=regions[preferredIdx]
295+
296+
baseURL,err:=url.Parse(region.PathAppURL)
297+
iferr!=nil {
298+
returnxerrors.Errorf("failed to parse proxy URL: %w",err)
299+
}
300+
baseURL.Path=""
301+
pathAppURL:=strings.TrimPrefix(region.PathAppURL,baseURL.String())
302+
appURL:=buildAppLinkURL(baseURL,ws,agt,foundApp,region.WildcardHostname,pathAppURL)
303+
304+
// Check if we're inside a workspace. Generally, we know
305+
// that if we're inside a workspace, `open` can't be used.
306+
insideAWorkspace:=inv.Environ.Get("CODER")=="true"
307+
ifinsideAWorkspace {
308+
_,_=fmt.Fprintf(inv.Stderr,"Please open the following URI on your local machine:\n\n")
309+
_,_=fmt.Fprintf(inv.Stdout,"%s\n",appURL)
310+
returnnil
311+
}
312+
_,_=fmt.Fprintf(inv.Stderr,"Opening %s\n",appURL)
313+
314+
if!testOpenError {
315+
err=open.Run(appURL)
316+
}else {
317+
err=xerrors.New("test.open-error")
318+
}
319+
returnerr
320+
},
321+
}
322+
323+
cmd.Options= serpent.OptionSet{
324+
{
325+
Flag:"region",
326+
Env:"CODER_OPEN_APP_REGION",
327+
Description:fmt.Sprintf("Region to use when opening the app."+
328+
" By default, the app will be opened using the main Coder deployment (a.k.a.\"primary\")."),
329+
Value:serpent.StringOf(&regionArg),
330+
Default:"primary",
331+
},
332+
{
333+
Flag:"test.open-error",
334+
Description:"Don't run the open command.",
335+
Value:serpent.BoolOf(&testOpenError),
336+
Hidden:true,// This is for testing!
337+
},
338+
}
339+
340+
returncmd
341+
}
342+
214343
// waitForAgentCond uses the watch workspace API to update the agent information
215344
// until the condition is met.
216345
funcwaitForAgentCond(ctx context.Context,client*codersdk.Client,workspace codersdk.Workspace,workspaceAgent codersdk.WorkspaceAgent,condfunc(codersdk.WorkspaceAgent)bool) (codersdk.Workspace, codersdk.WorkspaceAgent,error) {
@@ -337,3 +466,48 @@ func doAsync(f func()) (wait func()) {
337466
<-done
338467
}
339468
}
469+
470+
// buildAppLinkURL returns the URL to open the app in the browser.
471+
// It follows similar logic to the TypeScript implementation in site/src/utils/app.ts
472+
// except that all URLs returned are absolute and based on the provided base URL.
473+
funcbuildAppLinkURL(baseURL*url.URL,workspace codersdk.Workspace,agent codersdk.WorkspaceAgent,app codersdk.WorkspaceApp,appsHost,preferredPathBasestring)string {
474+
// If app is external, return the URL directly
475+
ifapp.External {
476+
returnapp.URL
477+
}
478+
479+
varu url.URL
480+
u.Scheme=baseURL.Scheme
481+
u.Host=baseURL.Host
482+
// We redirect if we don't include a trailing slash, so we always include one to avoid extra roundtrips.
483+
u.Path=fmt.Sprintf(
484+
"%s/@%s/%s.%s/apps/%s/",
485+
preferredPathBase,
486+
workspace.OwnerName,
487+
workspace.Name,
488+
agent.Name,
489+
url.PathEscape(app.Slug),
490+
)
491+
// The frontend leaves the returns a relative URL for the terminal, but we don't have that luxury.
492+
ifapp.Command!="" {
493+
u.Path=fmt.Sprintf(
494+
"%s/@%s/%s.%s/terminal",
495+
preferredPathBase,
496+
workspace.OwnerName,
497+
workspace.Name,
498+
agent.Name,
499+
)
500+
q:=u.Query()
501+
q.Set("command",app.Command)
502+
u.RawQuery=q.Encode()
503+
// encodeURIComponent replaces spaces with %20 but url.QueryEscape replaces them with +.
504+
// We replace them with %20 to match the TypeScript implementation.
505+
u.RawQuery=strings.ReplaceAll(u.RawQuery,"+","%20")
506+
}
507+
508+
ifappsHost!=""&&app.Subdomain&&app.SubdomainName!="" {
509+
u.Host=strings.Replace(appsHost,"*",app.SubdomainName,1)
510+
u.Path="/"
511+
}
512+
returnu.String()
513+
}

‎cli/open_internal_test.go

Lines changed: 113 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,14 @@
11
package cli
22

3-
import"testing"
3+
import (
4+
"net/url"
5+
"testing"
6+
7+
"github.com/stretchr/testify/assert"
8+
"github.com/stretchr/testify/require"
9+
10+
"github.com/coder/coder/v2/codersdk"
11+
)
412

513
funcTest_resolveAgentAbsPath(t*testing.T) {
614
t.Parallel()
@@ -54,3 +62,107 @@ func Test_resolveAgentAbsPath(t *testing.T) {
5462
})
5563
}
5664
}
65+
66+
funcTest_buildAppLinkURL(t*testing.T) {
67+
t.Parallel()
68+
69+
for_,tt:=range []struct {
70+
namestring
71+
// function arguments
72+
baseURLstring
73+
workspace codersdk.Workspace
74+
agent codersdk.WorkspaceAgent
75+
app codersdk.WorkspaceApp
76+
appsHoststring
77+
preferredPathBasestring
78+
// expected results
79+
expectedLinkstring
80+
}{
81+
{
82+
name:"external url",
83+
baseURL:"https://coder.tld",
84+
app: codersdk.WorkspaceApp{
85+
External:true,
86+
URL:"https://external-url.tld",
87+
},
88+
expectedLink:"https://external-url.tld",
89+
},
90+
{
91+
name:"without subdomain",
92+
baseURL:"https://coder.tld",
93+
workspace: codersdk.Workspace{
94+
Name:"Test-Workspace",
95+
OwnerName:"username",
96+
},
97+
agent: codersdk.WorkspaceAgent{
98+
Name:"a-workspace-agent",
99+
},
100+
app: codersdk.WorkspaceApp{
101+
Slug:"app-slug",
102+
Subdomain:false,
103+
},
104+
preferredPathBase:"/path-base",
105+
expectedLink:"https://coder.tld/path-base/@username/Test-Workspace.a-workspace-agent/apps/app-slug/",
106+
},
107+
{
108+
name:"with command",
109+
baseURL:"https://coder.tld",
110+
workspace: codersdk.Workspace{
111+
Name:"Test-Workspace",
112+
OwnerName:"username",
113+
},
114+
agent: codersdk.WorkspaceAgent{
115+
Name:"a-workspace-agent",
116+
},
117+
app: codersdk.WorkspaceApp{
118+
Command:"ls -la",
119+
},
120+
expectedLink:"https://coder.tld/@username/Test-Workspace.a-workspace-agent/terminal?command=ls%20-la",
121+
},
122+
{
123+
name:"with subdomain",
124+
baseURL:"ftps://coder.tld",
125+
workspace: codersdk.Workspace{
126+
Name:"Test-Workspace",
127+
OwnerName:"username",
128+
},
129+
agent: codersdk.WorkspaceAgent{
130+
Name:"a-workspace-agent",
131+
},
132+
app: codersdk.WorkspaceApp{
133+
Subdomain:true,
134+
SubdomainName:"hellocoder",
135+
},
136+
preferredPathBase:"/path-base",
137+
appsHost:"*.apps-host.tld",
138+
expectedLink:"ftps://hellocoder.apps-host.tld/",
139+
},
140+
{
141+
name:"with subdomain, but not apps host",
142+
baseURL:"https://coder.tld",
143+
workspace: codersdk.Workspace{
144+
Name:"Test-Workspace",
145+
OwnerName:"username",
146+
},
147+
agent: codersdk.WorkspaceAgent{
148+
Name:"a-workspace-agent",
149+
},
150+
app: codersdk.WorkspaceApp{
151+
Slug:"app-slug",
152+
Subdomain:true,
153+
SubdomainName:"It really doesn't matter what this is without AppsHost.",
154+
},
155+
preferredPathBase:"/path-base",
156+
expectedLink:"https://coder.tld/path-base/@username/Test-Workspace.a-workspace-agent/apps/app-slug/",
157+
},
158+
} {
159+
tt:=tt
160+
t.Run(tt.name,func(t*testing.T) {
161+
t.Parallel()
162+
baseURL,err:=url.Parse(tt.baseURL)
163+
require.NoError(t,err)
164+
actual:=buildAppLinkURL(baseURL,tt.workspace,tt.agent,tt.app,tt.appsHost,tt.preferredPathBase)
165+
assert.Equal(t,tt.expectedLink,actual)
166+
})
167+
}
168+
}

0 commit comments

Comments
 (0)

[8]ページ先頭

©2009-2025 Movatter.jp