mirror of
https://github.com/coder/coder.git
synced 2025-07-15 22:20:27 +00:00
feat(cli): add open app <workspace> <app-slug> command (#17032)
Fixes https://github.com/coder/coder/issues/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.
This commit is contained in:
174
cli/open.go
174
cli/open.go
@ -2,11 +2,14 @@ package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/skratchdot/open-golang/open"
|
||||
@ -26,6 +29,7 @@ func (r *RootCmd) open() *serpent.Command {
|
||||
},
|
||||
Children: []*serpent.Command{
|
||||
r.openVSCode(),
|
||||
r.openApp(),
|
||||
},
|
||||
}
|
||||
return cmd
|
||||
@ -211,6 +215,131 @@ func (r *RootCmd) openVSCode() *serpent.Command {
|
||||
return cmd
|
||||
}
|
||||
|
||||
func (r *RootCmd) openApp() *serpent.Command {
|
||||
var (
|
||||
regionArg string
|
||||
testOpenError bool
|
||||
)
|
||||
|
||||
client := new(codersdk.Client)
|
||||
cmd := &serpent.Command{
|
||||
Annotations: workspaceCommand,
|
||||
Use: "app <workspace> <app slug>",
|
||||
Short: "Open a workspace application.",
|
||||
Middleware: serpent.Chain(
|
||||
r.InitClient(client),
|
||||
),
|
||||
Handler: func(inv *serpent.Invocation) error {
|
||||
ctx, cancel := context.WithCancel(inv.Context())
|
||||
defer cancel()
|
||||
|
||||
if len(inv.Args) == 0 || len(inv.Args) > 2 {
|
||||
return inv.Command.HelpHandler(inv)
|
||||
}
|
||||
|
||||
workspaceName := inv.Args[0]
|
||||
ws, agt, err := getWorkspaceAndAgent(ctx, inv, client, false, workspaceName)
|
||||
if err != nil {
|
||||
var sdkErr *codersdk.Error
|
||||
if errors.As(err, &sdkErr) && sdkErr.StatusCode() == http.StatusNotFound {
|
||||
cliui.Errorf(inv.Stderr, "Workspace %q not found!", workspaceName)
|
||||
return sdkErr
|
||||
}
|
||||
cliui.Errorf(inv.Stderr, "Failed to get workspace and agent: %s", err)
|
||||
return err
|
||||
}
|
||||
|
||||
allAppSlugs := make([]string, len(agt.Apps))
|
||||
for i, app := range agt.Apps {
|
||||
allAppSlugs[i] = app.Slug
|
||||
}
|
||||
slices.Sort(allAppSlugs)
|
||||
|
||||
// If a user doesn't specify an app slug, we'll just list the available
|
||||
// apps and exit.
|
||||
if len(inv.Args) == 1 {
|
||||
cliui.Infof(inv.Stderr, "Available apps in %q: %v", workspaceName, allAppSlugs)
|
||||
return nil
|
||||
}
|
||||
|
||||
appSlug := inv.Args[1]
|
||||
var foundApp codersdk.WorkspaceApp
|
||||
appIdx := slices.IndexFunc(agt.Apps, func(a codersdk.WorkspaceApp) bool {
|
||||
return a.Slug == appSlug
|
||||
})
|
||||
if appIdx == -1 {
|
||||
cliui.Errorf(inv.Stderr, "App %q not found in workspace %q!\nAvailable apps: %v", appSlug, workspaceName, allAppSlugs)
|
||||
return xerrors.Errorf("app not found")
|
||||
}
|
||||
foundApp = agt.Apps[appIdx]
|
||||
|
||||
// To build the app URL, we need to know the wildcard hostname
|
||||
// and path app URL for the region.
|
||||
regions, err := client.Regions(ctx)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("failed to fetch regions: %w", err)
|
||||
}
|
||||
var region codersdk.Region
|
||||
preferredIdx := slices.IndexFunc(regions, func(r codersdk.Region) bool {
|
||||
return r.Name == regionArg
|
||||
})
|
||||
if preferredIdx == -1 {
|
||||
allRegions := make([]string, len(regions))
|
||||
for i, r := range regions {
|
||||
allRegions[i] = r.Name
|
||||
}
|
||||
cliui.Errorf(inv.Stderr, "Preferred region %q not found!\nAvailable regions: %v", regionArg, allRegions)
|
||||
return xerrors.Errorf("region not found")
|
||||
}
|
||||
region = regions[preferredIdx]
|
||||
|
||||
baseURL, err := url.Parse(region.PathAppURL)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("failed to parse proxy URL: %w", err)
|
||||
}
|
||||
baseURL.Path = ""
|
||||
pathAppURL := strings.TrimPrefix(region.PathAppURL, baseURL.String())
|
||||
appURL := buildAppLinkURL(baseURL, ws, agt, foundApp, region.WildcardHostname, pathAppURL)
|
||||
|
||||
// Check if we're inside a workspace. Generally, we know
|
||||
// that if we're inside a workspace, `open` can't be used.
|
||||
insideAWorkspace := inv.Environ.Get("CODER") == "true"
|
||||
if insideAWorkspace {
|
||||
_, _ = fmt.Fprintf(inv.Stderr, "Please open the following URI on your local machine:\n\n")
|
||||
_, _ = fmt.Fprintf(inv.Stdout, "%s\n", appURL)
|
||||
return nil
|
||||
}
|
||||
_, _ = fmt.Fprintf(inv.Stderr, "Opening %s\n", appURL)
|
||||
|
||||
if !testOpenError {
|
||||
err = open.Run(appURL)
|
||||
} else {
|
||||
err = xerrors.New("test.open-error")
|
||||
}
|
||||
return err
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Options = serpent.OptionSet{
|
||||
{
|
||||
Flag: "region",
|
||||
Env: "CODER_OPEN_APP_REGION",
|
||||
Description: fmt.Sprintf("Region to use when opening the app." +
|
||||
" By default, the app will be opened using the main Coder deployment (a.k.a. \"primary\")."),
|
||||
Value: serpent.StringOf(®ionArg),
|
||||
Default: "primary",
|
||||
},
|
||||
{
|
||||
Flag: "test.open-error",
|
||||
Description: "Don't run the open command.",
|
||||
Value: serpent.BoolOf(&testOpenError),
|
||||
Hidden: true, // This is for testing!
|
||||
},
|
||||
}
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
// waitForAgentCond uses the watch workspace API to update the agent information
|
||||
// until the condition is met.
|
||||
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()) {
|
||||
<-done
|
||||
}
|
||||
}
|
||||
|
||||
// buildAppLinkURL returns the URL to open the app in the browser.
|
||||
// It follows similar logic to the TypeScript implementation in site/src/utils/app.ts
|
||||
// except that all URLs returned are absolute and based on the provided base URL.
|
||||
func buildAppLinkURL(baseURL *url.URL, workspace codersdk.Workspace, agent codersdk.WorkspaceAgent, app codersdk.WorkspaceApp, appsHost, preferredPathBase string) string {
|
||||
// If app is external, return the URL directly
|
||||
if app.External {
|
||||
return app.URL
|
||||
}
|
||||
|
||||
var u url.URL
|
||||
u.Scheme = baseURL.Scheme
|
||||
u.Host = baseURL.Host
|
||||
// We redirect if we don't include a trailing slash, so we always include one to avoid extra roundtrips.
|
||||
u.Path = fmt.Sprintf(
|
||||
"%s/@%s/%s.%s/apps/%s/",
|
||||
preferredPathBase,
|
||||
workspace.OwnerName,
|
||||
workspace.Name,
|
||||
agent.Name,
|
||||
url.PathEscape(app.Slug),
|
||||
)
|
||||
// The frontend leaves the returns a relative URL for the terminal, but we don't have that luxury.
|
||||
if app.Command != "" {
|
||||
u.Path = fmt.Sprintf(
|
||||
"%s/@%s/%s.%s/terminal",
|
||||
preferredPathBase,
|
||||
workspace.OwnerName,
|
||||
workspace.Name,
|
||||
agent.Name,
|
||||
)
|
||||
q := u.Query()
|
||||
q.Set("command", app.Command)
|
||||
u.RawQuery = q.Encode()
|
||||
// encodeURIComponent replaces spaces with %20 but url.QueryEscape replaces them with +.
|
||||
// We replace them with %20 to match the TypeScript implementation.
|
||||
u.RawQuery = strings.ReplaceAll(u.RawQuery, "+", "%20")
|
||||
}
|
||||
|
||||
if appsHost != "" && app.Subdomain && app.SubdomainName != "" {
|
||||
u.Host = strings.Replace(appsHost, "*", app.SubdomainName, 1)
|
||||
u.Path = "/"
|
||||
}
|
||||
return u.String()
|
||||
}
|
||||
|
@ -1,6 +1,14 @@
|
||||
package cli
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"net/url"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
)
|
||||
|
||||
func Test_resolveAgentAbsPath(t *testing.T) {
|
||||
t.Parallel()
|
||||
@ -54,3 +62,107 @@ func Test_resolveAgentAbsPath(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_buildAppLinkURL(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
// function arguments
|
||||
baseURL string
|
||||
workspace codersdk.Workspace
|
||||
agent codersdk.WorkspaceAgent
|
||||
app codersdk.WorkspaceApp
|
||||
appsHost string
|
||||
preferredPathBase string
|
||||
// expected results
|
||||
expectedLink string
|
||||
}{
|
||||
{
|
||||
name: "external url",
|
||||
baseURL: "https://coder.tld",
|
||||
app: codersdk.WorkspaceApp{
|
||||
External: true,
|
||||
URL: "https://external-url.tld",
|
||||
},
|
||||
expectedLink: "https://external-url.tld",
|
||||
},
|
||||
{
|
||||
name: "without subdomain",
|
||||
baseURL: "https://coder.tld",
|
||||
workspace: codersdk.Workspace{
|
||||
Name: "Test-Workspace",
|
||||
OwnerName: "username",
|
||||
},
|
||||
agent: codersdk.WorkspaceAgent{
|
||||
Name: "a-workspace-agent",
|
||||
},
|
||||
app: codersdk.WorkspaceApp{
|
||||
Slug: "app-slug",
|
||||
Subdomain: false,
|
||||
},
|
||||
preferredPathBase: "/path-base",
|
||||
expectedLink: "https://coder.tld/path-base/@username/Test-Workspace.a-workspace-agent/apps/app-slug/",
|
||||
},
|
||||
{
|
||||
name: "with command",
|
||||
baseURL: "https://coder.tld",
|
||||
workspace: codersdk.Workspace{
|
||||
Name: "Test-Workspace",
|
||||
OwnerName: "username",
|
||||
},
|
||||
agent: codersdk.WorkspaceAgent{
|
||||
Name: "a-workspace-agent",
|
||||
},
|
||||
app: codersdk.WorkspaceApp{
|
||||
Command: "ls -la",
|
||||
},
|
||||
expectedLink: "https://coder.tld/@username/Test-Workspace.a-workspace-agent/terminal?command=ls%20-la",
|
||||
},
|
||||
{
|
||||
name: "with subdomain",
|
||||
baseURL: "ftps://coder.tld",
|
||||
workspace: codersdk.Workspace{
|
||||
Name: "Test-Workspace",
|
||||
OwnerName: "username",
|
||||
},
|
||||
agent: codersdk.WorkspaceAgent{
|
||||
Name: "a-workspace-agent",
|
||||
},
|
||||
app: codersdk.WorkspaceApp{
|
||||
Subdomain: true,
|
||||
SubdomainName: "hellocoder",
|
||||
},
|
||||
preferredPathBase: "/path-base",
|
||||
appsHost: "*.apps-host.tld",
|
||||
expectedLink: "ftps://hellocoder.apps-host.tld/",
|
||||
},
|
||||
{
|
||||
name: "with subdomain, but not apps host",
|
||||
baseURL: "https://coder.tld",
|
||||
workspace: codersdk.Workspace{
|
||||
Name: "Test-Workspace",
|
||||
OwnerName: "username",
|
||||
},
|
||||
agent: codersdk.WorkspaceAgent{
|
||||
Name: "a-workspace-agent",
|
||||
},
|
||||
app: codersdk.WorkspaceApp{
|
||||
Slug: "app-slug",
|
||||
Subdomain: true,
|
||||
SubdomainName: "It really doesn't matter what this is without AppsHost.",
|
||||
},
|
||||
preferredPathBase: "/path-base",
|
||||
expectedLink: "https://coder.tld/path-base/@username/Test-Workspace.a-workspace-agent/apps/app-slug/",
|
||||
},
|
||||
} {
|
||||
tt := tt
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
baseURL, err := url.Parse(tt.baseURL)
|
||||
require.NoError(t, err)
|
||||
actual := buildAppLinkURL(baseURL, tt.workspace, tt.agent, tt.app, tt.appsHost, tt.preferredPathBase)
|
||||
assert.Equal(t, tt.expectedLink, actual)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
103
cli/open_test.go
103
cli/open_test.go
@ -5,6 +5,7 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
@ -33,7 +34,7 @@ func TestOpenVSCode(t *testing.T) {
|
||||
})
|
||||
|
||||
_ = agenttest.New(t, client.URL, agentToken)
|
||||
_ = coderdtest.AwaitWorkspaceAgents(t, client, workspace.ID)
|
||||
_ = coderdtest.NewWorkspaceAgentWaiter(t, client, workspace.ID).Wait()
|
||||
|
||||
insideWorkspaceEnv := map[string]string{
|
||||
"CODER": "true",
|
||||
@ -168,7 +169,7 @@ func TestOpenVSCode_NoAgentDirectory(t *testing.T) {
|
||||
})
|
||||
|
||||
_ = agenttest.New(t, client.URL, agentToken)
|
||||
_ = coderdtest.AwaitWorkspaceAgents(t, client, workspace.ID)
|
||||
_ = coderdtest.NewWorkspaceAgentWaiter(t, client, workspace.ID).Wait()
|
||||
|
||||
insideWorkspaceEnv := map[string]string{
|
||||
"CODER": "true",
|
||||
@ -283,3 +284,101 @@ func TestOpenVSCode_NoAgentDirectory(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenApp(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("OK", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client, ws, _ := setupWorkspaceForAgent(t, func(agents []*proto.Agent) []*proto.Agent {
|
||||
agents[0].Apps = []*proto.App{
|
||||
{
|
||||
Slug: "app1",
|
||||
Url: "https://example.com/app1",
|
||||
},
|
||||
}
|
||||
return agents
|
||||
})
|
||||
|
||||
inv, root := clitest.New(t, "open", "app", ws.Name, "app1", "--test.open-error")
|
||||
clitest.SetupConfig(t, client, root)
|
||||
pty := ptytest.New(t)
|
||||
inv.Stdin = pty.Input()
|
||||
inv.Stdout = pty.Output()
|
||||
|
||||
w := clitest.StartWithWaiter(t, inv)
|
||||
w.RequireError()
|
||||
w.RequireContains("test.open-error")
|
||||
})
|
||||
|
||||
t.Run("OnlyWorkspaceName", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client, ws, _ := setupWorkspaceForAgent(t)
|
||||
inv, root := clitest.New(t, "open", "app", ws.Name)
|
||||
clitest.SetupConfig(t, client, root)
|
||||
var sb strings.Builder
|
||||
inv.Stdout = &sb
|
||||
inv.Stderr = &sb
|
||||
|
||||
w := clitest.StartWithWaiter(t, inv)
|
||||
w.RequireSuccess()
|
||||
|
||||
require.Contains(t, sb.String(), "Available apps in")
|
||||
})
|
||||
|
||||
t.Run("WorkspaceNotFound", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client, _, _ := setupWorkspaceForAgent(t)
|
||||
inv, root := clitest.New(t, "open", "app", "not-a-workspace", "app1")
|
||||
clitest.SetupConfig(t, client, root)
|
||||
pty := ptytest.New(t)
|
||||
inv.Stdin = pty.Input()
|
||||
inv.Stdout = pty.Output()
|
||||
w := clitest.StartWithWaiter(t, inv)
|
||||
w.RequireError()
|
||||
w.RequireContains("Resource not found or you do not have access to this resource")
|
||||
})
|
||||
|
||||
t.Run("AppNotFound", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client, ws, _ := setupWorkspaceForAgent(t)
|
||||
|
||||
inv, root := clitest.New(t, "open", "app", ws.Name, "app1")
|
||||
clitest.SetupConfig(t, client, root)
|
||||
pty := ptytest.New(t)
|
||||
inv.Stdin = pty.Input()
|
||||
inv.Stdout = pty.Output()
|
||||
|
||||
w := clitest.StartWithWaiter(t, inv)
|
||||
w.RequireError()
|
||||
w.RequireContains("app not found")
|
||||
})
|
||||
|
||||
t.Run("RegionNotFound", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client, ws, _ := setupWorkspaceForAgent(t, func(agents []*proto.Agent) []*proto.Agent {
|
||||
agents[0].Apps = []*proto.App{
|
||||
{
|
||||
Slug: "app1",
|
||||
Url: "https://example.com/app1",
|
||||
},
|
||||
}
|
||||
return agents
|
||||
})
|
||||
|
||||
inv, root := clitest.New(t, "open", "app", ws.Name, "app1", "--region", "bad-region")
|
||||
clitest.SetupConfig(t, client, root)
|
||||
pty := ptytest.New(t)
|
||||
inv.Stdin = pty.Input()
|
||||
inv.Stdout = pty.Output()
|
||||
|
||||
w := clitest.StartWithWaiter(t, inv)
|
||||
w.RequireError()
|
||||
w.RequireContains("region not found")
|
||||
})
|
||||
}
|
||||
|
1
cli/testdata/coder_open_--help.golden
vendored
1
cli/testdata/coder_open_--help.golden
vendored
@ -6,6 +6,7 @@ USAGE:
|
||||
Open a workspace
|
||||
|
||||
SUBCOMMANDS:
|
||||
app Open a workspace application.
|
||||
vscode Open a workspace in VS Code Desktop
|
||||
|
||||
———
|
||||
|
14
cli/testdata/coder_open_app_--help.golden
vendored
Normal file
14
cli/testdata/coder_open_app_--help.golden
vendored
Normal file
@ -0,0 +1,14 @@
|
||||
coder v0.0.0-devel
|
||||
|
||||
USAGE:
|
||||
coder open app [flags] <workspace> <app slug>
|
||||
|
||||
Open a workspace application.
|
||||
|
||||
OPTIONS:
|
||||
--region string, $CODER_OPEN_APP_REGION (default: primary)
|
||||
Region to use when opening the app. By default, the app will be opened
|
||||
using the main Coder deployment (a.k.a. "primary").
|
||||
|
||||
———
|
||||
Run `coder --help` for a list of global options.
|
@ -1065,6 +1065,11 @@
|
||||
"description": "Open a workspace",
|
||||
"path": "reference/cli/open.md"
|
||||
},
|
||||
{
|
||||
"title": "open app",
|
||||
"description": "Open a workspace application.",
|
||||
"path": "reference/cli/open_app.md"
|
||||
},
|
||||
{
|
||||
"title": "open vscode",
|
||||
"description": "Open a workspace in VS Code Desktop",
|
||||
|
1
docs/reference/cli/open.md
generated
1
docs/reference/cli/open.md
generated
@ -14,3 +14,4 @@ coder open
|
||||
| Name | Purpose |
|
||||
|-----------------------------------------|-------------------------------------|
|
||||
| [<code>vscode</code>](./open_vscode.md) | Open a workspace in VS Code Desktop |
|
||||
| [<code>app</code>](./open_app.md) | Open a workspace application. |
|
||||
|
22
docs/reference/cli/open_app.md
generated
Normal file
22
docs/reference/cli/open_app.md
generated
Normal file
@ -0,0 +1,22 @@
|
||||
<!-- DO NOT EDIT | GENERATED CONTENT -->
|
||||
# open app
|
||||
|
||||
Open a workspace application.
|
||||
|
||||
## Usage
|
||||
|
||||
```console
|
||||
coder open app [flags] <workspace> <app slug>
|
||||
```
|
||||
|
||||
## Options
|
||||
|
||||
### --region
|
||||
|
||||
| | |
|
||||
|-------------|-------------------------------------|
|
||||
| Type | <code>string</code> |
|
||||
| Environment | <code>$CODER_OPEN_APP_REGION</code> |
|
||||
| Default | <code>primary</code> |
|
||||
|
||||
Region to use when opening the app. By default, the app will be opened using the main Coder deployment (a.k.a. "primary").
|
Reference in New Issue
Block a user