feat(cli): add enterprise external-workspaces CLI command (#19287)

This pull request introduces support for external workspace management, allowing users to register and manage workspaces that are provisioned and managed outside of the Coder.

* coder external-workspaces create - Creates a new external workspace (this command extends coder create)
  * Example: coder external-workspaces create ext-workspace --template=externally-managed-workspace -y
  * Checks if template has coder_external_agent resource before creating a workspace
* coder external-workspaces list - Lists all external workspaces
* coder external-workspaces agent-instructions <workspace name> <agent name> - Retrieves agent connection instruction
  * Example: coder external-workspaces agent-instructions ext-workspace main --output=json
This commit is contained in:
Kacper Sawicki
2025-08-19 10:52:31 +02:00
committed by GitHub
parent 9edceef0bf
commit 7b1dcd9846
25 changed files with 1277 additions and 73 deletions
+21 -1
View File
@@ -29,7 +29,12 @@ const PresetNone = "none"
var ErrNoPresetFound = xerrors.New("no preset found")
func (r *RootCmd) create() *serpent.Command {
type CreateOptions struct {
BeforeCreate func(ctx context.Context, client *codersdk.Client, template codersdk.Template, templateVersionID uuid.UUID) error
AfterCreate func(ctx context.Context, inv *serpent.Invocation, client *codersdk.Client, workspace codersdk.Workspace) error
}
func (r *RootCmd) Create(opts CreateOptions) *serpent.Command {
var (
templateName string
templateVersion string
@@ -305,6 +310,13 @@ func (r *RootCmd) create() *serpent.Command {
_, _ = fmt.Fprintf(inv.Stdout, "%s", cliui.Bold("No preset applied."))
}
if opts.BeforeCreate != nil {
err = opts.BeforeCreate(inv.Context(), client, template, templateVersionID)
if err != nil {
return xerrors.Errorf("before create: %w", err)
}
}
richParameters, err := prepWorkspaceBuild(inv, client, prepWorkspaceBuildArgs{
Action: WorkspaceCreate,
TemplateVersionID: templateVersionID,
@@ -366,6 +378,14 @@ func (r *RootCmd) create() *serpent.Command {
cliui.Keyword(workspace.Name),
cliui.Timestamp(time.Now()),
)
if opts.AfterCreate != nil {
err = opts.AfterCreate(inv.Context(), inv, client, workspace)
if err != nil {
return err
}
}
return nil
},
}
+1 -1
View File
@@ -97,7 +97,7 @@ func handleRPTY(inv *serpent.Invocation, client *codersdk.Client, args handleRPT
reconnectID = uuid.New()
}
ws, agt, _, err := getWorkspaceAndAgent(ctx, inv, client, true, args.NamedWorkspace)
ws, agt, _, err := GetWorkspaceAndAgent(ctx, inv, client, true, args.NamedWorkspace)
if err != nil {
return err
}
+7 -7
View File
@@ -18,7 +18,7 @@ import (
// workspaceListRow is the type provided to the OutputFormatter. This is a bit
// dodgy but it's the only way to do complex display code for one format vs. the
// other.
type workspaceListRow struct {
type WorkspaceListRow struct {
// For JSON format:
codersdk.Workspace `table:"-"`
@@ -40,7 +40,7 @@ type workspaceListRow struct {
DailyCost string `json:"-" table:"daily cost"`
}
func workspaceListRowFromWorkspace(now time.Time, workspace codersdk.Workspace) workspaceListRow {
func WorkspaceListRowFromWorkspace(now time.Time, workspace codersdk.Workspace) WorkspaceListRow {
status := codersdk.WorkspaceDisplayStatus(workspace.LatestBuild.Job.Status, workspace.LatestBuild.Transition)
lastBuilt := now.UTC().Sub(workspace.LatestBuild.Job.CreatedAt).Truncate(time.Second)
@@ -55,7 +55,7 @@ func workspaceListRowFromWorkspace(now time.Time, workspace codersdk.Workspace)
favIco = "★"
}
workspaceName := favIco + " " + workspace.OwnerName + "/" + workspace.Name
return workspaceListRow{
return WorkspaceListRow{
Favorite: workspace.Favorite,
Workspace: workspace,
WorkspaceName: workspaceName,
@@ -80,7 +80,7 @@ func (r *RootCmd) list() *serpent.Command {
filter cliui.WorkspaceFilter
formatter = cliui.NewOutputFormatter(
cliui.TableFormat(
[]workspaceListRow{},
[]WorkspaceListRow{},
[]string{
"workspace",
"template",
@@ -107,7 +107,7 @@ func (r *RootCmd) list() *serpent.Command {
r.InitClient(client),
),
Handler: func(inv *serpent.Invocation) error {
res, err := queryConvertWorkspaces(inv.Context(), client, filter.Filter(), workspaceListRowFromWorkspace)
res, err := QueryConvertWorkspaces(inv.Context(), client, filter.Filter(), WorkspaceListRowFromWorkspace)
if err != nil {
return err
}
@@ -137,9 +137,9 @@ func (r *RootCmd) list() *serpent.Command {
// queryConvertWorkspaces is a helper function for converting
// codersdk.Workspaces to a different type.
// It's used by the list command to convert workspaces to
// workspaceListRow, and by the schedule command to
// WorkspaceListRow, and by the schedule command to
// convert workspaces to scheduleListRow.
func queryConvertWorkspaces[T any](ctx context.Context, client *codersdk.Client, filter codersdk.WorkspaceFilter, convertF func(time.Time, codersdk.Workspace) T) ([]T, error) {
func QueryConvertWorkspaces[T any](ctx context.Context, client *codersdk.Client, filter codersdk.WorkspaceFilter, convertF func(time.Time, codersdk.Workspace) T) ([]T, error) {
var empty []T
workspaces, err := client.Workspaces(ctx, filter)
if err != nil {
+2 -2
View File
@@ -72,7 +72,7 @@ func (r *RootCmd) openVSCode() *serpent.Command {
// need to wait for the agent to start.
workspaceQuery := inv.Args[0]
autostart := true
workspace, workspaceAgent, otherWorkspaceAgents, err := getWorkspaceAndAgent(ctx, inv, client, autostart, workspaceQuery)
workspace, workspaceAgent, otherWorkspaceAgents, err := GetWorkspaceAndAgent(ctx, inv, client, autostart, workspaceQuery)
if err != nil {
return xerrors.Errorf("get workspace and agent: %w", err)
}
@@ -316,7 +316,7 @@ func (r *RootCmd) openApp() *serpent.Command {
}
workspaceName := inv.Args[0]
ws, agt, _, err := getWorkspaceAndAgent(ctx, inv, client, false, workspaceName)
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 {
+1 -1
View File
@@ -110,7 +110,7 @@ func (r *RootCmd) ping() *serpent.Command {
defer notifyCancel()
workspaceName := inv.Args[0]
_, workspaceAgent, _, err := getWorkspaceAndAgent(
_, workspaceAgent, _, err := GetWorkspaceAndAgent(
ctx, inv, client,
false, // Do not autostart for a ping.
workspaceName,
+1 -1
View File
@@ -84,7 +84,7 @@ func (r *RootCmd) portForward() *serpent.Command {
return xerrors.New("no port-forwards requested")
}
workspace, workspaceAgent, _, err := getWorkspaceAndAgent(ctx, inv, client, !disableAutostart, inv.Args[0])
workspace, workspaceAgent, _, err := GetWorkspaceAndAgent(ctx, inv, client, !disableAutostart, inv.Args[0])
if err != nil {
return err
}
+1 -1
View File
@@ -108,7 +108,7 @@ func (r *RootCmd) CoreSubcommands() []*serpent.Command {
// Workspace Commands
r.autoupdate(),
r.configSSH(),
r.create(),
r.Create(CreateOptions{}),
r.deleteWorkspace(),
r.favorite(),
r.list(),
+2 -2
View File
@@ -117,7 +117,7 @@ func (r *RootCmd) scheduleShow() *serpent.Command {
f.FilterQuery = fmt.Sprintf("owner:me name:%s", inv.Args[0])
}
}
res, err := queryConvertWorkspaces(inv.Context(), client, f, scheduleListRowFromWorkspace)
res, err := QueryConvertWorkspaces(inv.Context(), client, f, scheduleListRowFromWorkspace)
if err != nil {
return err
}
@@ -307,7 +307,7 @@ func (r *RootCmd) scheduleExtend() *serpent.Command {
}
func displaySchedule(ws codersdk.Workspace, out io.Writer) error {
rows := []workspaceListRow{workspaceListRowFromWorkspace(time.Now(), ws)}
rows := []WorkspaceListRow{WorkspaceListRowFromWorkspace(time.Now(), ws)}
rendered, err := cliui.DisplayTable(rows, "workspace", []string{
"workspace", "starts at", "starts next", "stops after", "stops next",
})
+1 -1
View File
@@ -83,7 +83,7 @@ func (r *RootCmd) speedtest() *serpent.Command {
return xerrors.Errorf("--direct (-d) is incompatible with --%s", varDisableDirect)
}
_, workspaceAgent, _, err := getWorkspaceAndAgent(ctx, inv, client, false, inv.Args[0])
_, workspaceAgent, _, err := GetWorkspaceAndAgent(ctx, inv, client, false, inv.Args[0])
if err != nil {
return err
}
+4 -4
View File
@@ -754,7 +754,7 @@ func findWorkspaceAndAgentByHostname(
hostname = strings.TrimSuffix(hostname, qualifiedSuffix)
}
hostname = normalizeWorkspaceInput(hostname)
ws, agent, _, err := getWorkspaceAndAgent(ctx, inv, client, !disableAutostart, hostname)
ws, agent, _, err := GetWorkspaceAndAgent(ctx, inv, client, !disableAutostart, hostname)
return ws, agent, err
}
@@ -827,11 +827,11 @@ startWatchLoop:
}
}
// getWorkspaceAgent returns the workspace and agent selected using either the
// GetWorkspaceAndAgent returns the workspace and agent selected using either the
// `<workspace>[.<agent>]` syntax via `in`. It will also return any other agents
// in the workspace as a slice for use in child->parent lookups.
// If autoStart is true, the workspace will be started if it is not already running.
func getWorkspaceAndAgent(ctx context.Context, inv *serpent.Invocation, client *codersdk.Client, autostart bool, input string) (codersdk.Workspace, codersdk.WorkspaceAgent, []codersdk.WorkspaceAgent, error) { //nolint:revive
func GetWorkspaceAndAgent(ctx context.Context, inv *serpent.Invocation, client *codersdk.Client, autostart bool, input string) (codersdk.Workspace, codersdk.WorkspaceAgent, []codersdk.WorkspaceAgent, error) { //nolint:revive
var (
workspace codersdk.Workspace
// The input will be `owner/name.agent`
@@ -880,7 +880,7 @@ func getWorkspaceAndAgent(ctx context.Context, inv *serpent.Invocation, client *
switch cerr.StatusCode() {
case http.StatusConflict:
_, _ = fmt.Fprintln(inv.Stderr, "Unable to start the workspace due to conflict, the workspace may be starting, retrying without autostart...")
return getWorkspaceAndAgent(ctx, inv, client, false, input)
return GetWorkspaceAndAgent(ctx, inv, client, false, input)
case http.StatusForbidden:
_, err = startWorkspace(inv, client, workspace, workspaceParameterFlags{}, buildFlags{}, WorkspaceUpdate)
+1 -1
View File
@@ -102,7 +102,7 @@ func (r *RootCmd) vscodeSSH() *serpent.Command {
// will call this command after the workspace is started.
autostart := false
workspace, workspaceAgent, _, err := getWorkspaceAndAgent(ctx, inv, client, autostart, fmt.Sprintf("%s/%s", owner, name))
workspace, workspaceAgent, _, err := GetWorkspaceAndAgent(ctx, inv, client, autostart, fmt.Sprintf("%s/%s", owner, name))
if err != nil {
return xerrors.Errorf("find workspace and agent: %w", err)
}
+20
View File
@@ -1159,6 +1159,26 @@
"description": "Print auth for an external provider",
"path": "reference/cli/external-auth_access-token.md"
},
{
"title": "external-workspaces",
"description": "Create or manage external workspaces",
"path": "reference/cli/external-workspaces.md"
},
{
"title": "external-workspaces agent-instructions",
"description": "Get the instructions for an external agent",
"path": "reference/cli/external-workspaces_agent-instructions.md"
},
{
"title": "external-workspaces create",
"description": "Create a new external workspace",
"path": "reference/cli/external-workspaces_create.md"
},
{
"title": "external-workspaces list",
"description": "List external workspaces",
"path": "reference/cli/external-workspaces_list.md"
},
{
"title": "favorite",
"description": "Add a workspace to your favorites",
+29
View File
@@ -0,0 +1,29 @@
<!-- DO NOT EDIT | GENERATED CONTENT -->
# external-workspaces
Create or manage external workspaces
## Usage
```console
coder external-workspaces [flags] [subcommand]
```
## Subcommands
| Name | Purpose |
|--------------------------------------------------------------------------------|--------------------------------------------|
| [<code>create</code>](./external-workspaces_create.md) | Create a new external workspace |
| [<code>agent-instructions</code>](./external-workspaces_agent-instructions.md) | Get the instructions for an external agent |
| [<code>list</code>](./external-workspaces_list.md) | List external workspaces |
## Options
### -O, --org
| | |
|-------------|----------------------------------|
| Type | <code>string</code> |
| Environment | <code>$CODER_ORGANIZATION</code> |
Select which organization (uuid or name) to use.
@@ -0,0 +1,21 @@
<!-- DO NOT EDIT | GENERATED CONTENT -->
# external-workspaces agent-instructions
Get the instructions for an external agent
## Usage
```console
coder external-workspaces agent-instructions [flags] [user/]workspace[.agent]
```
## Options
### -o, --output
| | |
|---------|-------------------------|
| Type | <code>text\|json</code> |
| Default | <code>text</code> |
Output format.
+128
View File
@@ -0,0 +1,128 @@
<!-- DO NOT EDIT | GENERATED CONTENT -->
# external-workspaces create
Create a new external workspace
## Usage
```console
coder external-workspaces create [flags] [workspace]
```
## Description
```console
- Create a workspace for another user (if you have permission):
$ coder create <username>/<workspace_name>
```
## Options
### -t, --template
| | |
|-------------|-----------------------------------|
| Type | <code>string</code> |
| Environment | <code>$CODER_TEMPLATE_NAME</code> |
Specify a template name.
### --template-version
| | |
|-------------|--------------------------------------|
| Type | <code>string</code> |
| Environment | <code>$CODER_TEMPLATE_VERSION</code> |
Specify a template version name.
### --preset
| | |
|-------------|---------------------------------|
| Type | <code>string</code> |
| Environment | <code>$CODER_PRESET_NAME</code> |
Specify the name of a template version preset. Use 'none' to explicitly indicate that no preset should be used.
### --start-at
| | |
|-------------|----------------------------------------|
| Type | <code>string</code> |
| Environment | <code>$CODER_WORKSPACE_START_AT</code> |
Specify the workspace autostart schedule. Check coder schedule start --help for the syntax.
### --stop-after
| | |
|-------------|------------------------------------------|
| Type | <code>duration</code> |
| Environment | <code>$CODER_WORKSPACE_STOP_AFTER</code> |
Specify a duration after which the workspace should shut down (e.g. 8h).
### --automatic-updates
| | |
|-------------|-------------------------------------------------|
| Type | <code>string</code> |
| Environment | <code>$CODER_WORKSPACE_AUTOMATIC_UPDATES</code> |
| Default | <code>never</code> |
Specify automatic updates setting for the workspace (accepts 'always' or 'never').
### --copy-parameters-from
| | |
|-------------|----------------------------------------------------|
| Type | <code>string</code> |
| Environment | <code>$CODER_WORKSPACE_COPY_PARAMETERS_FROM</code> |
Specify the source workspace name to copy parameters from.
### -y, --yes
| | |
|------|-------------------|
| Type | <code>bool</code> |
Bypass prompts.
### --parameter
| | |
|-------------|------------------------------------|
| Type | <code>string-array</code> |
| Environment | <code>$CODER_RICH_PARAMETER</code> |
Rich parameter value in the format "name=value".
### --rich-parameter-file
| | |
|-------------|-----------------------------------------|
| Type | <code>string</code> |
| Environment | <code>$CODER_RICH_PARAMETER_FILE</code> |
Specify a file path with values for rich parameters defined in the template. The file should be in YAML format, containing key-value pairs for the parameters.
### --parameter-default
| | |
|-------------|--------------------------------------------|
| Type | <code>string-array</code> |
| Environment | <code>$CODER_RICH_PARAMETER_DEFAULT</code> |
Rich parameter default values in the format "name=value".
### -O, --org
| | |
|-------------|----------------------------------|
| Type | <code>string</code> |
| Environment | <code>$CODER_ORGANIZATION</code> |
Select which organization (uuid or name) to use.
+51
View File
@@ -0,0 +1,51 @@
<!-- DO NOT EDIT | GENERATED CONTENT -->
# external-workspaces list
List external workspaces
Aliases:
* ls
## Usage
```console
coder external-workspaces list [flags]
```
## Options
### -a, --all
| | |
|------|-------------------|
| Type | <code>bool</code> |
Specifies whether all workspaces will be listed or not.
### --search
| | |
|---------|-----------------------|
| Type | <code>string</code> |
| Default | <code>owner:me</code> |
Search for a workspace with a query.
### -c, --column
| | |
|---------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| Type | <code>[favorite\|workspace\|organization id\|organization name\|template\|status\|healthy\|last built\|current version\|outdated\|starts at\|starts next\|stops after\|stops next\|daily cost]</code> |
| Default | <code>workspace,template,status,healthy,last built,current version,outdated</code> |
Columns to display in table output.
### -o, --output
| | |
|---------|--------------------------|
| Type | <code>table\|json</code> |
| Default | <code>table</code> |
Output format.
+46 -45
View File
@@ -22,51 +22,52 @@ Coder — A tool for provisioning self-hosted development environments with Terr
## Subcommands
| Name | Purpose |
|----------------------------------------------------|------------------------------------------------------------------------------------------------------------------------------|
| [<code>completion</code>](./completion.md) | Install or update shell completion scripts for the detected or chosen shell. |
| [<code>dotfiles</code>](./dotfiles.md) | Personalize your workspace by applying a canonical dotfiles repository |
| [<code>external-auth</code>](./external-auth.md) | Manage external authentication |
| [<code>login</code>](./login.md) | Authenticate with Coder deployment |
| [<code>logout</code>](./logout.md) | Unauthenticate your local session |
| [<code>netcheck</code>](./netcheck.md) | Print network debug information for DERP and STUN |
| [<code>notifications</code>](./notifications.md) | Manage Coder notifications |
| [<code>organizations</code>](./organizations.md) | Organization related commands |
| [<code>port-forward</code>](./port-forward.md) | Forward ports from a workspace to the local machine. For reverse port forwarding, use "coder ssh -R". |
| [<code>publickey</code>](./publickey.md) | Output your Coder public key used for Git operations |
| [<code>reset-password</code>](./reset-password.md) | Directly connect to the database to reset a user's password |
| [<code>state</code>](./state.md) | Manually manage Terraform state to fix broken workspaces |
| [<code>templates</code>](./templates.md) | Manage templates |
| [<code>tokens</code>](./tokens.md) | Manage personal access tokens |
| [<code>users</code>](./users.md) | Manage users |
| [<code>version</code>](./version.md) | Show coder version |
| [<code>autoupdate</code>](./autoupdate.md) | Toggle auto-update policy for a workspace |
| [<code>config-ssh</code>](./config-ssh.md) | Add an SSH Host entry for your workspaces "ssh workspace.coder" |
| [<code>create</code>](./create.md) | Create a workspace |
| [<code>delete</code>](./delete.md) | Delete a workspace |
| [<code>favorite</code>](./favorite.md) | Add a workspace to your favorites |
| [<code>list</code>](./list.md) | List workspaces |
| [<code>open</code>](./open.md) | Open a workspace |
| [<code>ping</code>](./ping.md) | Ping a workspace |
| [<code>rename</code>](./rename.md) | Rename a workspace |
| [<code>restart</code>](./restart.md) | Restart a workspace |
| [<code>schedule</code>](./schedule.md) | Schedule automated start and stop times for workspaces |
| [<code>show</code>](./show.md) | Display details of a workspace's resources and agents |
| [<code>speedtest</code>](./speedtest.md) | Run upload and download tests from your machine to a workspace |
| [<code>ssh</code>](./ssh.md) | Start a shell into a workspace or run a command |
| [<code>start</code>](./start.md) | Start a workspace |
| [<code>stat</code>](./stat.md) | Show resource usage for the current workspace. |
| [<code>stop</code>](./stop.md) | Stop a workspace |
| [<code>unfavorite</code>](./unfavorite.md) | Remove a workspace from your favorites |
| [<code>update</code>](./update.md) | Will update and start a given workspace if it is out of date. If the workspace is already running, it will be stopped first. |
| [<code>whoami</code>](./whoami.md) | Fetch authenticated user info for Coder deployment |
| [<code>support</code>](./support.md) | Commands for troubleshooting issues with a Coder deployment. |
| [<code>server</code>](./server.md) | Start a Coder server |
| [<code>features</code>](./features.md) | List Enterprise features |
| [<code>licenses</code>](./licenses.md) | Add, delete, and list licenses |
| [<code>groups</code>](./groups.md) | Manage groups |
| [<code>prebuilds</code>](./prebuilds.md) | Manage Coder prebuilds |
| [<code>provisioner</code>](./provisioner.md) | View and manage provisioner daemons and jobs |
| Name | Purpose |
|--------------------------------------------------------------|------------------------------------------------------------------------------------------------------------------------------|
| [<code>completion</code>](./completion.md) | Install or update shell completion scripts for the detected or chosen shell. |
| [<code>dotfiles</code>](./dotfiles.md) | Personalize your workspace by applying a canonical dotfiles repository |
| [<code>external-auth</code>](./external-auth.md) | Manage external authentication |
| [<code>login</code>](./login.md) | Authenticate with Coder deployment |
| [<code>logout</code>](./logout.md) | Unauthenticate your local session |
| [<code>netcheck</code>](./netcheck.md) | Print network debug information for DERP and STUN |
| [<code>notifications</code>](./notifications.md) | Manage Coder notifications |
| [<code>organizations</code>](./organizations.md) | Organization related commands |
| [<code>port-forward</code>](./port-forward.md) | Forward ports from a workspace to the local machine. For reverse port forwarding, use "coder ssh -R". |
| [<code>publickey</code>](./publickey.md) | Output your Coder public key used for Git operations |
| [<code>reset-password</code>](./reset-password.md) | Directly connect to the database to reset a user's password |
| [<code>state</code>](./state.md) | Manually manage Terraform state to fix broken workspaces |
| [<code>templates</code>](./templates.md) | Manage templates |
| [<code>tokens</code>](./tokens.md) | Manage personal access tokens |
| [<code>users</code>](./users.md) | Manage users |
| [<code>version</code>](./version.md) | Show coder version |
| [<code>autoupdate</code>](./autoupdate.md) | Toggle auto-update policy for a workspace |
| [<code>config-ssh</code>](./config-ssh.md) | Add an SSH Host entry for your workspaces "ssh workspace.coder" |
| [<code>create</code>](./create.md) | Create a workspace |
| [<code>delete</code>](./delete.md) | Delete a workspace |
| [<code>favorite</code>](./favorite.md) | Add a workspace to your favorites |
| [<code>list</code>](./list.md) | List workspaces |
| [<code>open</code>](./open.md) | Open a workspace |
| [<code>ping</code>](./ping.md) | Ping a workspace |
| [<code>rename</code>](./rename.md) | Rename a workspace |
| [<code>restart</code>](./restart.md) | Restart a workspace |
| [<code>schedule</code>](./schedule.md) | Schedule automated start and stop times for workspaces |
| [<code>show</code>](./show.md) | Display details of a workspace's resources and agents |
| [<code>speedtest</code>](./speedtest.md) | Run upload and download tests from your machine to a workspace |
| [<code>ssh</code>](./ssh.md) | Start a shell into a workspace or run a command |
| [<code>start</code>](./start.md) | Start a workspace |
| [<code>stat</code>](./stat.md) | Show resource usage for the current workspace. |
| [<code>stop</code>](./stop.md) | Stop a workspace |
| [<code>unfavorite</code>](./unfavorite.md) | Remove a workspace from your favorites |
| [<code>update</code>](./update.md) | Will update and start a given workspace if it is out of date. If the workspace is already running, it will be stopped first. |
| [<code>whoami</code>](./whoami.md) | Fetch authenticated user info for Coder deployment |
| [<code>support</code>](./support.md) | Commands for troubleshooting issues with a Coder deployment. |
| [<code>server</code>](./server.md) | Start a Coder server |
| [<code>features</code>](./features.md) | List Enterprise features |
| [<code>licenses</code>](./licenses.md) | Add, delete, and list licenses |
| [<code>groups</code>](./groups.md) | Manage groups |
| [<code>prebuilds</code>](./prebuilds.md) | Manage Coder prebuilds |
| [<code>provisioner</code>](./provisioner.md) | View and manage provisioner daemons and jobs |
| [<code>external-workspaces</code>](./external-workspaces.md) | Create or manage external workspaces |
## Options
+262
View File
@@ -0,0 +1,262 @@
package cli
import (
"context"
"fmt"
"strings"
"github.com/google/uuid"
"golang.org/x/xerrors"
agpl "github.com/coder/coder/v2/cli"
"github.com/coder/coder/v2/cli/cliui"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/pretty"
"github.com/coder/serpent"
)
type externalAgent struct {
WorkspaceName string `json:"workspace_name"`
AgentName string `json:"agent_name"`
AuthType string `json:"auth_type"`
AuthToken string `json:"auth_token"`
InitScript string `json:"init_script"`
}
func (r *RootCmd) externalWorkspaces() *serpent.Command {
orgContext := agpl.NewOrganizationContext()
cmd := &serpent.Command{
Use: "external-workspaces [subcommand]",
Short: "Create or manage external workspaces",
Handler: func(inv *serpent.Invocation) error {
return inv.Command.HelpHandler(inv)
},
Children: []*serpent.Command{
r.externalWorkspaceCreate(),
r.externalWorkspaceAgentInstructions(),
r.externalWorkspaceList(),
},
}
orgContext.AttachOptions(cmd)
return cmd
}
// externalWorkspaceCreate extends `coder create` to create an external workspace.
func (r *RootCmd) externalWorkspaceCreate() *serpent.Command {
opts := agpl.CreateOptions{
BeforeCreate: func(ctx context.Context, client *codersdk.Client, _ codersdk.Template, templateVersionID uuid.UUID) error {
version, err := client.TemplateVersion(ctx, templateVersionID)
if err != nil {
return xerrors.Errorf("get template version: %w", err)
}
if !version.HasExternalAgent {
return xerrors.Errorf("template version %q does not have an external agent. Only templates with external agents can be used for external workspace creation", templateVersionID)
}
return nil
},
AfterCreate: func(ctx context.Context, inv *serpent.Invocation, client *codersdk.Client, workspace codersdk.Workspace) error {
workspace, err := client.WorkspaceByOwnerAndName(ctx, codersdk.Me, workspace.Name, codersdk.WorkspaceOptions{})
if err != nil {
return xerrors.Errorf("get workspace by name: %w", err)
}
externalAgents, err := fetchExternalAgents(inv, client, workspace, workspace.LatestBuild.Resources)
if err != nil {
return xerrors.Errorf("fetch external agents: %w", err)
}
formatted := formatExternalAgent(workspace.Name, externalAgents)
_, err = fmt.Fprintln(inv.Stdout, formatted)
return err
},
}
cmd := r.Create(opts)
cmd.Use = "create [workspace]"
cmd.Short = "Create a new external workspace"
cmd.Middleware = serpent.Chain(
cmd.Middleware,
serpent.RequireNArgs(1),
)
for i := range cmd.Options {
if cmd.Options[i].Flag == "template" {
cmd.Options[i].Required = true
}
}
return cmd
}
// externalWorkspaceAgentInstructions prints the instructions for an external agent.
func (r *RootCmd) externalWorkspaceAgentInstructions() *serpent.Command {
client := new(codersdk.Client)
formatter := cliui.NewOutputFormatter(
cliui.ChangeFormatterData(cliui.TextFormat(), func(data any) (any, error) {
agent, ok := data.(externalAgent)
if !ok {
return "", xerrors.Errorf("expected externalAgent, got %T", data)
}
return formatExternalAgent(agent.WorkspaceName, []externalAgent{agent}), nil
}),
cliui.JSONFormat(),
)
cmd := &serpent.Command{
Use: "agent-instructions [user/]workspace[.agent]",
Short: "Get the instructions for an external agent",
Middleware: serpent.Chain(r.InitClient(client), serpent.RequireNArgs(1)),
Handler: func(inv *serpent.Invocation) error {
workspace, workspaceAgent, _, err := agpl.GetWorkspaceAndAgent(inv.Context(), inv, client, false, inv.Args[0])
if err != nil {
return xerrors.Errorf("find workspace and agent: %w", err)
}
credentials, err := client.WorkspaceExternalAgentCredentials(inv.Context(), workspace.ID, workspaceAgent.Name)
if err != nil {
return xerrors.Errorf("get external agent token for agent %q: %w", workspaceAgent.Name, err)
}
agentInfo := externalAgent{
WorkspaceName: workspace.Name,
AgentName: workspaceAgent.Name,
AuthType: "token",
AuthToken: credentials.AgentToken,
InitScript: credentials.Command,
}
out, err := formatter.Format(inv.Context(), agentInfo)
if err != nil {
return err
}
_, err = fmt.Fprintln(inv.Stdout, out)
return err
},
}
formatter.AttachOptions(&cmd.Options)
return cmd
}
func (r *RootCmd) externalWorkspaceList() *serpent.Command {
var (
filter cliui.WorkspaceFilter
formatter = cliui.NewOutputFormatter(
cliui.TableFormat(
[]agpl.WorkspaceListRow{},
[]string{
"workspace",
"template",
"status",
"healthy",
"last built",
"current version",
"outdated",
},
),
cliui.JSONFormat(),
)
)
client := new(codersdk.Client)
cmd := &serpent.Command{
Annotations: map[string]string{
"workspaces": "",
},
Use: "list",
Short: "List external workspaces",
Aliases: []string{"ls"},
Middleware: serpent.Chain(
serpent.RequireNArgs(0),
r.InitClient(client),
),
Handler: func(inv *serpent.Invocation) error {
baseFilter := filter.Filter()
if baseFilter.FilterQuery == "" {
baseFilter.FilterQuery = "has_external_agent:true"
} else {
baseFilter.FilterQuery += " has_external_agent:true"
}
res, err := agpl.QueryConvertWorkspaces(inv.Context(), client, baseFilter, agpl.WorkspaceListRowFromWorkspace)
if err != nil {
return err
}
if len(res) == 0 && formatter.FormatID() != cliui.JSONFormat().ID() {
pretty.Fprintf(inv.Stderr, cliui.DefaultStyles.Prompt, "No workspaces found! Create one:\n")
_, _ = fmt.Fprintln(inv.Stderr)
_, _ = fmt.Fprintln(inv.Stderr, " "+pretty.Sprint(cliui.DefaultStyles.Code, "coder external-workspaces create <name>"))
_, _ = fmt.Fprintln(inv.Stderr)
return nil
}
out, err := formatter.Format(inv.Context(), res)
if err != nil {
return err
}
_, err = fmt.Fprintln(inv.Stdout, out)
return err
},
}
filter.AttachOptions(&cmd.Options)
formatter.AttachOptions(&cmd.Options)
return cmd
}
// fetchExternalAgents fetches the external agents for a workspace.
func fetchExternalAgents(inv *serpent.Invocation, client *codersdk.Client, workspace codersdk.Workspace, resources []codersdk.WorkspaceResource) ([]externalAgent, error) {
if len(resources) == 0 {
return nil, xerrors.Errorf("no resources found for workspace")
}
var externalAgents []externalAgent
for _, resource := range resources {
if resource.Type != "coder_external_agent" || len(resource.Agents) == 0 {
continue
}
agent := resource.Agents[0]
credentials, err := client.WorkspaceExternalAgentCredentials(inv.Context(), workspace.ID, agent.Name)
if err != nil {
return nil, xerrors.Errorf("get external agent token for agent %q: %w", agent.Name, err)
}
externalAgents = append(externalAgents, externalAgent{
AgentName: agent.Name,
AuthType: "token",
AuthToken: credentials.AgentToken,
InitScript: credentials.Command,
})
}
return externalAgents, nil
}
// formatExternalAgent formats the instructions for an external agent.
func formatExternalAgent(workspaceName string, externalAgents []externalAgent) string {
var output strings.Builder
_, _ = output.WriteString(fmt.Sprintf("\nPlease run the following commands to attach external agent to the workspace %s:\n\n", cliui.Keyword(workspaceName)))
for i, agent := range externalAgents {
if len(externalAgents) > 1 {
_, _ = output.WriteString(fmt.Sprintf("For agent %s:\n", cliui.Keyword(agent.AgentName)))
}
_, _ = output.WriteString(fmt.Sprintf("%s\n", pretty.Sprint(cliui.DefaultStyles.Code, fmt.Sprintf("export CODER_AGENT_TOKEN=%s", agent.AuthToken))))
_, _ = output.WriteString(fmt.Sprintf("%s\n", pretty.Sprint(cliui.DefaultStyles.Code, fmt.Sprintf("curl -fsSL %s | sh", agent.InitScript))))
if i < len(externalAgents)-1 {
_, _ = output.WriteString("\n")
}
}
return output.String()
}
+559
View File
@@ -0,0 +1,559 @@
package cli_test
import (
"bytes"
"context"
"encoding/json"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/coder/coder/v2/cli/clitest"
"github.com/coder/coder/v2/coderd/coderdtest"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/enterprise/coderd/coderdenttest"
"github.com/coder/coder/v2/enterprise/coderd/license"
"github.com/coder/coder/v2/provisioner/echo"
"github.com/coder/coder/v2/provisionersdk/proto"
"github.com/coder/coder/v2/pty/ptytest"
"github.com/coder/coder/v2/testutil"
)
// completeWithExternalAgent creates a template version with an external agent resource
func completeWithExternalAgent() *echo.Responses {
return &echo.Responses{
Parse: echo.ParseComplete,
ProvisionPlan: []*proto.Response{
{
Type: &proto.Response_Plan{
Plan: &proto.PlanComplete{
Resources: []*proto.Resource{
{
Type: "coder_external_agent",
Name: "main",
Agents: []*proto.Agent{
{
Name: "external-agent",
OperatingSystem: "linux",
Architecture: "amd64",
},
},
},
},
HasExternalAgents: true,
},
},
},
},
ProvisionApply: []*proto.Response{
{
Type: &proto.Response_Apply{
Apply: &proto.ApplyComplete{
Resources: []*proto.Resource{
{
Type: "coder_external_agent",
Name: "main",
Agents: []*proto.Agent{
{
Name: "external-agent",
OperatingSystem: "linux",
Architecture: "amd64",
},
},
},
},
},
},
},
},
}
}
// completeWithRegularAgent creates a template version with a regular agent (no external agent)
func completeWithRegularAgent() *echo.Responses {
return &echo.Responses{
Parse: echo.ParseComplete,
ProvisionPlan: []*proto.Response{
{
Type: &proto.Response_Plan{
Plan: &proto.PlanComplete{
Resources: []*proto.Resource{
{
Type: "compute",
Name: "main",
Agents: []*proto.Agent{
{
Name: "regular-agent",
OperatingSystem: "linux",
Architecture: "amd64",
},
},
},
},
},
},
},
},
ProvisionApply: []*proto.Response{
{
Type: &proto.Response_Apply{
Apply: &proto.ApplyComplete{
Resources: []*proto.Resource{
{
Type: "compute",
Name: "main",
Agents: []*proto.Agent{
{
Name: "regular-agent",
OperatingSystem: "linux",
Architecture: "amd64",
},
},
},
},
},
},
},
},
}
}
func TestExternalWorkspaces(t *testing.T) {
t.Parallel()
t.Run("Create", func(t *testing.T) {
t.Parallel()
client, owner := coderdenttest.New(t, &coderdenttest.Options{
Options: &coderdtest.Options{
IncludeProvisionerDaemon: true,
},
LicenseOptions: &coderdenttest.LicenseOptions{
Features: license.Features{
codersdk.FeatureWorkspaceExternalAgent: 1,
},
},
})
member, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, completeWithExternalAgent())
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID)
args := []string{
"external-workspaces",
"create",
"my-external-workspace",
"--template", template.Name,
}
inv, root := newCLI(t, args...)
clitest.SetupConfig(t, member, root)
doneChan := make(chan struct{})
pty := ptytest.New(t).Attach(inv)
go func() {
defer close(doneChan)
err := inv.Run()
assert.NoError(t, err)
}()
// Expect the workspace creation confirmation
pty.ExpectMatch("coder_external_agent.main")
pty.ExpectMatch("external-agent (linux, amd64)")
pty.ExpectMatch("Confirm create")
pty.WriteLine("yes")
// Expect the external agent instructions
pty.ExpectMatch("Please run the following commands to attach external agent")
pty.ExpectMatch("export CODER_AGENT_TOKEN=")
pty.ExpectMatch("curl -fsSL")
<-doneChan
// Verify the workspace was created
ws, err := member.WorkspaceByOwnerAndName(context.Background(), codersdk.Me, "my-external-workspace", codersdk.WorkspaceOptions{})
require.NoError(t, err)
assert.Equal(t, template.Name, ws.TemplateName)
})
t.Run("CreateWithoutTemplate", func(t *testing.T) {
t.Parallel()
client, owner := coderdenttest.New(t, &coderdenttest.Options{
Options: &coderdtest.Options{
IncludeProvisionerDaemon: true,
},
LicenseOptions: &coderdenttest.LicenseOptions{
Features: license.Features{
codersdk.FeatureWorkspaceExternalAgent: 1,
},
},
})
member, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
args := []string{
"external-workspaces",
"create",
"my-external-workspace",
}
inv, root := newCLI(t, args...)
clitest.SetupConfig(t, member, root)
err := inv.Run()
require.Error(t, err)
assert.Contains(t, err.Error(), "Missing values for the required flags: template")
})
t.Run("CreateWithRegularTemplate", func(t *testing.T) {
t.Parallel()
client, owner := coderdenttest.New(t, &coderdenttest.Options{
Options: &coderdtest.Options{
IncludeProvisionerDaemon: true,
},
LicenseOptions: &coderdenttest.LicenseOptions{
Features: license.Features{
codersdk.FeatureWorkspaceExternalAgent: 1,
},
},
})
member, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, completeWithRegularAgent())
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID)
args := []string{
"external-workspaces",
"create",
"my-external-workspace",
"--template", template.Name,
}
inv, root := newCLI(t, args...)
clitest.SetupConfig(t, member, root)
err := inv.Run()
require.Error(t, err)
assert.Contains(t, err.Error(), "does not have an external agent")
})
t.Run("List", func(t *testing.T) {
t.Parallel()
client, owner := coderdenttest.New(t, &coderdenttest.Options{
Options: &coderdtest.Options{
IncludeProvisionerDaemon: true,
},
LicenseOptions: &coderdenttest.LicenseOptions{
Features: license.Features{
codersdk.FeatureWorkspaceExternalAgent: 1,
},
},
})
member, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, completeWithExternalAgent())
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID)
// Create an external workspace
ws := coderdtest.CreateWorkspace(t, member, template.ID)
coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, ws.LatestBuild.ID)
args := []string{
"external-workspaces",
"list",
}
inv, root := newCLI(t, args...)
clitest.SetupConfig(t, member, root)
pty := ptytest.New(t).Attach(inv)
ctx, cancelFunc := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancelFunc()
done := make(chan any)
go func() {
errC := inv.WithContext(ctx).Run()
assert.NoError(t, errC)
close(done)
}()
pty.ExpectMatch(ws.Name)
pty.ExpectMatch(template.Name)
cancelFunc()
<-done
})
t.Run("ListJSON", func(t *testing.T) {
t.Parallel()
client, owner := coderdenttest.New(t, &coderdenttest.Options{
Options: &coderdtest.Options{
IncludeProvisionerDaemon: true,
},
LicenseOptions: &coderdenttest.LicenseOptions{
Features: license.Features{
codersdk.FeatureWorkspaceExternalAgent: 1,
},
},
})
member, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, completeWithExternalAgent())
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID)
// Create an external workspace
ws := coderdtest.CreateWorkspace(t, member, template.ID)
coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, ws.LatestBuild.ID)
args := []string{
"external-workspaces",
"list",
"--output=json",
}
inv, root := newCLI(t, args...)
clitest.SetupConfig(t, member, root)
ctx, cancelFunc := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancelFunc()
out := bytes.NewBuffer(nil)
inv.Stdout = out
err := inv.WithContext(ctx).Run()
require.NoError(t, err)
var workspaces []codersdk.Workspace
require.NoError(t, json.Unmarshal(out.Bytes(), &workspaces))
require.Len(t, workspaces, 1)
assert.Equal(t, ws.Name, workspaces[0].Name)
})
t.Run("ListNoWorkspaces", func(t *testing.T) {
t.Parallel()
client, owner := coderdenttest.New(t, &coderdenttest.Options{
Options: &coderdtest.Options{
IncludeProvisionerDaemon: true,
},
LicenseOptions: &coderdenttest.LicenseOptions{
Features: license.Features{
codersdk.FeatureWorkspaceExternalAgent: 1,
},
},
})
member, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
args := []string{
"external-workspaces",
"list",
}
inv, root := newCLI(t, args...)
clitest.SetupConfig(t, member, root)
pty := ptytest.New(t).Attach(inv)
ctx, cancelFunc := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancelFunc()
done := make(chan any)
go func() {
errC := inv.WithContext(ctx).Run()
assert.NoError(t, errC)
close(done)
}()
pty.ExpectMatch("No workspaces found!")
pty.ExpectMatch("coder external-workspaces create")
cancelFunc()
<-done
})
t.Run("AgentInstructions", func(t *testing.T) {
t.Parallel()
client, owner := coderdenttest.New(t, &coderdenttest.Options{
Options: &coderdtest.Options{
IncludeProvisionerDaemon: true,
},
LicenseOptions: &coderdenttest.LicenseOptions{
Features: license.Features{
codersdk.FeatureWorkspaceExternalAgent: 1,
},
},
})
member, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, completeWithExternalAgent())
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID)
// Create an external workspace
ws := coderdtest.CreateWorkspace(t, member, template.ID)
coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, ws.LatestBuild.ID)
args := []string{
"external-workspaces",
"agent-instructions",
ws.Name,
}
inv, root := newCLI(t, args...)
clitest.SetupConfig(t, member, root)
pty := ptytest.New(t).Attach(inv)
ctx, cancelFunc := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancelFunc()
done := make(chan any)
go func() {
errC := inv.WithContext(ctx).Run()
assert.NoError(t, errC)
close(done)
}()
pty.ExpectMatch("Please run the following commands to attach external agent to the workspace")
pty.ExpectMatch("export CODER_AGENT_TOKEN=")
pty.ExpectMatch("curl -fsSL")
cancelFunc()
<-done
})
t.Run("AgentInstructionsJSON", func(t *testing.T) {
t.Parallel()
client, owner := coderdenttest.New(t, &coderdenttest.Options{
Options: &coderdtest.Options{
IncludeProvisionerDaemon: true,
},
LicenseOptions: &coderdenttest.LicenseOptions{
Features: license.Features{
codersdk.FeatureWorkspaceExternalAgent: 1,
},
},
})
member, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, completeWithExternalAgent())
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID)
// Create an external workspace
ws := coderdtest.CreateWorkspace(t, member, template.ID)
coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, ws.LatestBuild.ID)
args := []string{
"external-workspaces",
"agent-instructions",
ws.Name,
"--output=json",
}
inv, root := newCLI(t, args...)
clitest.SetupConfig(t, member, root)
ctx, cancelFunc := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancelFunc()
out := bytes.NewBuffer(nil)
inv.Stdout = out
err := inv.WithContext(ctx).Run()
require.NoError(t, err)
var agentInfo map[string]interface{}
require.NoError(t, json.Unmarshal(out.Bytes(), &agentInfo))
assert.Equal(t, "token", agentInfo["auth_type"])
assert.NotEmpty(t, agentInfo["auth_token"])
assert.NotEmpty(t, agentInfo["init_script"])
})
t.Run("AgentInstructionsNonExistentWorkspace", func(t *testing.T) {
t.Parallel()
client, owner := coderdenttest.New(t, &coderdenttest.Options{
Options: &coderdtest.Options{
IncludeProvisionerDaemon: true,
},
LicenseOptions: &coderdenttest.LicenseOptions{
Features: license.Features{
codersdk.FeatureWorkspaceExternalAgent: 1,
},
},
})
member, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
args := []string{
"external-workspaces",
"agent-instructions",
"non-existent-workspace",
}
inv, root := newCLI(t, args...)
clitest.SetupConfig(t, member, root)
err := inv.Run()
require.Error(t, err)
assert.Contains(t, err.Error(), "Resource not found")
})
t.Run("AgentInstructionsNonExistentAgent", func(t *testing.T) {
t.Parallel()
client, owner := coderdenttest.New(t, &coderdenttest.Options{
Options: &coderdtest.Options{
IncludeProvisionerDaemon: true,
},
LicenseOptions: &coderdenttest.LicenseOptions{
Features: license.Features{
codersdk.FeatureWorkspaceExternalAgent: 1,
},
},
})
member, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, completeWithExternalAgent())
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID)
// Create an external workspace
ws := coderdtest.CreateWorkspace(t, member, template.ID)
coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, ws.LatestBuild.ID)
args := []string{
"external-workspaces",
"agent-instructions",
ws.Name + ".non-existent-agent",
}
inv, root := newCLI(t, args...)
clitest.SetupConfig(t, member, root)
err := inv.Run()
require.Error(t, err)
assert.Contains(t, err.Error(), "agent not found by name")
})
t.Run("CreateWithTemplateVersion", func(t *testing.T) {
t.Parallel()
client, owner := coderdenttest.New(t, &coderdenttest.Options{
Options: &coderdtest.Options{
IncludeProvisionerDaemon: true,
},
LicenseOptions: &coderdenttest.LicenseOptions{
Features: license.Features{
codersdk.FeatureWorkspaceExternalAgent: 1,
},
},
})
member, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, completeWithExternalAgent())
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID)
args := []string{
"external-workspaces",
"create",
"my-external-workspace",
"--template", template.Name,
"--template-version", version.Name,
"-y",
}
inv, root := newCLI(t, args...)
clitest.SetupConfig(t, member, root)
doneChan := make(chan struct{})
pty := ptytest.New(t).Attach(inv)
go func() {
defer close(doneChan)
err := inv.Run()
assert.NoError(t, err)
}()
// Expect the workspace creation confirmation
pty.ExpectMatch("coder_external_agent.main")
pty.ExpectMatch("external-agent (linux, amd64)")
// Expect the external agent instructions
pty.ExpectMatch("Please run the following commands to attach external agent")
pty.ExpectMatch("export CODER_AGENT_TOKEN=")
pty.ExpectMatch("curl -fsSL")
<-doneChan
// Verify the workspace was created
ws, err := member.WorkspaceByOwnerAndName(context.Background(), codersdk.Me, "my-external-workspace", codersdk.WorkspaceOptions{})
require.NoError(t, err)
assert.Equal(t, template.Name, ws.TemplateName)
})
}
+1
View File
@@ -19,6 +19,7 @@ func (r *RootCmd) enterpriseOnly() []*serpent.Command {
r.prebuilds(),
r.provisionerDaemons(),
r.provisionerd(),
r.externalWorkspaces(),
}
}
+7 -6
View File
@@ -14,12 +14,13 @@ USAGE:
$ coder templates init
SUBCOMMANDS:
features List Enterprise features
groups Manage groups
licenses Add, delete, and list licenses
prebuilds Manage Coder prebuilds
provisioner View and manage provisioner daemons and jobs
server Start a Coder server
external-workspaces Create or manage external workspaces
features List Enterprise features
groups Manage groups
licenses Add, delete, and list licenses
prebuilds Manage Coder prebuilds
provisioner View and manage provisioner daemons and jobs
server Start a Coder server
GLOBAL OPTIONS:
Global options are applied to all commands. They can be set using environment
@@ -0,0 +1,18 @@
coder v0.0.0-devel
USAGE:
coder external-workspaces [flags] [subcommand]
Create or manage external workspaces
SUBCOMMANDS:
agent-instructions Get the instructions for an external agent
create Create a new external workspace
list List external workspaces
OPTIONS:
-O, --org string, $CODER_ORGANIZATION
Select which organization (uuid or name) to use.
———
Run `coder --help` for a list of global options.
@@ -0,0 +1,13 @@
coder v0.0.0-devel
USAGE:
coder external-workspaces agent-instructions [flags] [user/]workspace[.agent]
Get the instructions for an external agent
OPTIONS:
-o, --output text|json (default: text)
Output format.
———
Run `coder --help` for a list of global options.
@@ -0,0 +1,56 @@
coder v0.0.0-devel
USAGE:
coder external-workspaces create [flags] [workspace]
Create a new external workspace
- Create a workspace for another user (if you have permission):
$ coder create <username>/<workspace_name>
OPTIONS:
-O, --org string, $CODER_ORGANIZATION
Select which organization (uuid or name) to use.
--automatic-updates string, $CODER_WORKSPACE_AUTOMATIC_UPDATES (default: never)
Specify automatic updates setting for the workspace (accepts 'always'
or 'never').
--copy-parameters-from string, $CODER_WORKSPACE_COPY_PARAMETERS_FROM
Specify the source workspace name to copy parameters from.
--parameter string-array, $CODER_RICH_PARAMETER
Rich parameter value in the format "name=value".
--parameter-default string-array, $CODER_RICH_PARAMETER_DEFAULT
Rich parameter default values in the format "name=value".
--preset string, $CODER_PRESET_NAME
Specify the name of a template version preset. Use 'none' to
explicitly indicate that no preset should be used.
--rich-parameter-file string, $CODER_RICH_PARAMETER_FILE
Specify a file path with values for rich parameters defined in the
template. The file should be in YAML format, containing key-value
pairs for the parameters.
--start-at string, $CODER_WORKSPACE_START_AT
Specify the workspace autostart schedule. Check coder schedule start
--help for the syntax.
--stop-after duration, $CODER_WORKSPACE_STOP_AFTER
Specify a duration after which the workspace should shut down (e.g.
8h).
-t, --template string, $CODER_TEMPLATE_NAME
Specify a template name.
--template-version string, $CODER_TEMPLATE_VERSION
Specify a template version name.
-y, --yes bool
Bypass prompts.
———
Run `coder --help` for a list of global options.
@@ -0,0 +1,24 @@
coder v0.0.0-devel
USAGE:
coder external-workspaces list [flags]
List external workspaces
Aliases: ls
OPTIONS:
-a, --all bool
Specifies whether all workspaces will be listed or not.
-c, --column [favorite|workspace|organization id|organization name|template|status|healthy|last built|current version|outdated|starts at|starts next|stops after|stops next|daily cost] (default: workspace,template,status,healthy,last built,current version,outdated)
Columns to display in table output.
-o, --output table|json (default: table)
Output format.
--search string (default: owner:me)
Search for a workspace with a query.
———
Run `coder --help` for a list of global options.