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