Skip to content

Commit 0474888

Browse files
authored
feat(cli): add open app <workspace> <app-slug> command (#17032)
Fixes #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

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
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

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
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)
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy