Skip to content

Commit 0474888

Browse files
authored
feat(cli): add open app <workspace> <app-slug> command (coder#17032)
Fixes coder#17009 Adds a CLI command `coder open app <workspace> <app-slug>` that allows opening arbitrary `coder_apps` via the CLI. Users can optionally specify a region for workspace applications.
1 parent 3b6bee9 commit 0474888

File tree

8 files changed

+431
-3
lines changed

8 files changed

+431
-3
lines changed

cli/open.go

+174
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
return cmd
@@ -211,6 +215,131 @@ func (r *RootCmd) openVSCode() *serpent.Command {
211215
return cmd
212216
}
213217

218+
func (r *RootCmd) openApp() *serpent.Command {
219+
var (
220+
regionArg string
221+
testOpenError bool
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+
defer cancel()
235+
236+
if len(inv.Args) == 0 || len(inv.Args) > 2 {
237+
return inv.Command.HelpHandler(inv)
238+
}
239+
240+
workspaceName := inv.Args[0]
241+
ws, agt, err := getWorkspaceAndAgent(ctx, inv, client, false, workspaceName)
242+
if err != nil {
243+
var sdkErr *codersdk.Error
244+
if errors.As(err, &sdkErr) && sdkErr.StatusCode() == http.StatusNotFound {
245+
cliui.Errorf(inv.Stderr, "Workspace %q not found!", workspaceName)
246+
return sdkErr
247+
}
248+
cliui.Errorf(inv.Stderr, "Failed to get workspace and agent: %s", err)
249+
return err
250+
}
251+
252+
allAppSlugs := make([]string, len(agt.Apps))
253+
for i, app := range agt.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+
if len(inv.Args) == 1 {
261+
cliui.Infof(inv.Stderr, "Available apps in %q: %v", workspaceName, allAppSlugs)
262+
return nil
263+
}
264+
265+
appSlug := inv.Args[1]
266+
var foundApp codersdk.WorkspaceApp
267+
appIdx := slices.IndexFunc(agt.Apps, func(a codersdk.WorkspaceApp) bool {
268+
return a.Slug == appSlug
269+
})
270+
if appIdx == -1 {
271+
cliui.Errorf(inv.Stderr, "App %q not found in workspace %q!\nAvailable apps: %v", appSlug, workspaceName, allAppSlugs)
272+
return xerrors.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+
if err != nil {
280+
return xerrors.Errorf("failed to fetch regions: %w", err)
281+
}
282+
var region codersdk.Region
283+
preferredIdx := slices.IndexFunc(regions, func(r codersdk.Region) bool {
284+
return r.Name == regionArg
285+
})
286+
if preferredIdx == -1 {
287+
allRegions := make([]string, len(regions))
288+
for i, r := range regions {
289+
allRegions[i] = r.Name
290+
}
291+
cliui.Errorf(inv.Stderr, "Preferred region %q not found!\nAvailable regions: %v", regionArg, allRegions)
292+
return xerrors.Errorf("region not found")
293+
}
294+
region = regions[preferredIdx]
295+
296+
baseURL, err := url.Parse(region.PathAppURL)
297+
if err != nil {
298+
return xerrors.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+
if insideAWorkspace {
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+
return nil
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+
return err
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+
return cmd
341+
}
342+
214343
// waitForAgentCond uses the watch workspace API to update the agent information
215344
// until the condition is met.
216345
func waitForAgentCond(ctx context.Context, client *codersdk.Client, workspace codersdk.Workspace, workspaceAgent codersdk.WorkspaceAgent, cond func(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+
func buildAppLinkURL(baseURL *url.URL, workspace codersdk.Workspace, agent codersdk.WorkspaceAgent, app codersdk.WorkspaceApp, appsHost, preferredPathBase string) string {
474+
// If app is external, return the URL directly
475+
if app.External {
476+
return app.URL
477+
}
478+
479+
var u 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+
if app.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+
if appsHost != "" && app.Subdomain && app.SubdomainName != "" {
509+
u.Host = strings.Replace(appsHost, "*", app.SubdomainName, 1)
510+
u.Path = "/"
511+
}
512+
return u.String()
513+
}

cli/open_internal_test.go

+113-1
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
func Test_resolveAgentAbsPath(t *testing.T) {
614
t.Parallel()
@@ -54,3 +62,107 @@ func Test_resolveAgentAbsPath(t *testing.T) {
5462
})
5563
}
5664
}
65+
66+
func Test_buildAppLinkURL(t *testing.T) {
67+
t.Parallel()
68+
69+
for _, tt := range []struct {
70+
name string
71+
// function arguments
72+
baseURL string
73+
workspace codersdk.Workspace
74+
agent codersdk.WorkspaceAgent
75+
app codersdk.WorkspaceApp
76+
appsHost string
77+
preferredPathBase string
78+
// expected results
79+
expectedLink string
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)