mirror of
https://github.com/coder/coder.git
synced 2026-06-02 20:48:20 +00:00
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:
+21
-1
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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.
|
||||
Generated
+46
-45
@@ -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
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -19,6 +19,7 @@ func (r *RootCmd) enterpriseOnly() []*serpent.Command {
|
||||
r.prebuilds(),
|
||||
r.provisionerDaemons(),
|
||||
r.provisionerd(),
|
||||
r.externalWorkspaces(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+7
-6
@@ -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.
|
||||
+13
@@ -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.
|
||||
Reference in New Issue
Block a user