Skip to content

Commit 21765f9

Browse files
committed
feat(cli): add external-workspaces CLI command to create, list and manage external workspaces
1 parent 07a9c42 commit 21765f9

15 files changed

+1219
-95
lines changed

cli/create.go

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,12 @@ const PresetNone = "none"
2929

3030
var ErrNoPresetFound = xerrors.New("no preset found")
3131

32-
func (r *RootCmd) create() *serpent.Command {
32+
type createOptions struct {
33+
beforeCreate func(ctx context.Context, client *codersdk.Client, template codersdk.Template, templateVersionID uuid.UUID) error
34+
afterCreate func(ctx context.Context, inv *serpent.Invocation, client *codersdk.Client, workspace codersdk.Workspace) error
35+
}
36+
37+
func (r *RootCmd) create(opts createOptions) *serpent.Command {
3338
var (
3439
templateName string
3540
templateVersion string
@@ -305,6 +310,13 @@ func (r *RootCmd) create() *serpent.Command {
305310
_, _ = fmt.Fprintf(inv.Stdout, "%s", cliui.Bold("No preset applied."))
306311
}
307312

313+
if opts.beforeCreate != nil {
314+
err = opts.beforeCreate(inv.Context(), client, template, templateVersionID)
315+
if err != nil {
316+
return xerrors.Errorf("before create: %w", err)
317+
}
318+
}
319+
308320
richParameters, err := prepWorkspaceBuild(inv, client, prepWorkspaceBuildArgs{
309321
Action: WorkspaceCreate,
310322
TemplateVersionID: templateVersionID,
@@ -366,6 +378,14 @@ func (r *RootCmd) create() *serpent.Command {
366378
cliui.Keyword(workspace.Name),
367379
cliui.Timestamp(time.Now()),
368380
)
381+
382+
if opts.afterCreate != nil {
383+
err = opts.afterCreate(inv.Context(), inv, client, workspace)
384+
if err != nil {
385+
return err
386+
}
387+
}
388+
369389
return nil
370390
},
371391
}

cli/externalworkspaces.go

Lines changed: 271 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,271 @@
1+
package cli
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"strings"
7+
8+
"github.com/google/uuid"
9+
"golang.org/x/xerrors"
10+
11+
"github.com/coder/coder/v2/cli/cliui"
12+
"github.com/coder/coder/v2/codersdk"
13+
"github.com/coder/pretty"
14+
"github.com/coder/serpent"
15+
)
16+
17+
type externalAgent struct {
18+
WorkspaceName string `json:"-"`
19+
AgentName string `json:"-"`
20+
AuthType string `json:"auth_type"`
21+
AuthToken string `json:"auth_token"`
22+
InitScript string `json:"init_script"`
23+
}
24+
25+
func (r *RootCmd) externalWorkspaces() *serpent.Command {
26+
orgContext := NewOrganizationContext()
27+
28+
cmd := &serpent.Command{
29+
Use: "external-workspaces [subcommand]",
30+
Short: "Create or manage external workspaces",
31+
Handler: func(inv *serpent.Invocation) error {
32+
return inv.Command.HelpHandler(inv)
33+
},
34+
Children: []*serpent.Command{
35+
r.externalWorkspaceCreate(),
36+
r.externalWorkspaceAgentInstructions(),
37+
r.externalWorkspaceList(),
38+
},
39+
}
40+
41+
orgContext.AttachOptions(cmd)
42+
return cmd
43+
}
44+
45+
// externalWorkspaceCreate extends `coder create` to create an external workspace.
46+
func (r *RootCmd) externalWorkspaceCreate() *serpent.Command {
47+
opts := createOptions{
48+
beforeCreate: func(ctx context.Context, client *codersdk.Client, _ codersdk.Template, templateVersionID uuid.UUID) error {
49+
resources, err := client.TemplateVersionResources(ctx, templateVersionID)
50+
if err != nil {
51+
return xerrors.Errorf("get template version resources: %w", err)
52+
}
53+
if len(resources) == 0 {
54+
return xerrors.Errorf("no resources found for template version %q", templateVersionID)
55+
}
56+
57+
var hasExternalAgent bool
58+
for _, resource := range resources {
59+
if resource.Type == "coder_external_agent" {
60+
hasExternalAgent = true
61+
break
62+
}
63+
}
64+
65+
if !hasExternalAgent {
66+
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)
67+
}
68+
69+
return nil
70+
},
71+
afterCreate: func(ctx context.Context, inv *serpent.Invocation, client *codersdk.Client, workspace codersdk.Workspace) error {
72+
workspace, err := client.WorkspaceByOwnerAndName(ctx, codersdk.Me, workspace.Name, codersdk.WorkspaceOptions{})
73+
if err != nil {
74+
return xerrors.Errorf("get workspace by name: %w", err)
75+
}
76+
77+
externalAgents, err := fetchExternalAgents(inv, client, workspace, workspace.LatestBuild.Resources)
78+
if err != nil {
79+
return xerrors.Errorf("fetch external agents: %w", err)
80+
}
81+
82+
formatted := formatExternalAgent(workspace.Name, externalAgents)
83+
_, err = fmt.Fprintln(inv.Stdout, formatted)
84+
return err
85+
},
86+
}
87+
88+
cmd := r.create(opts)
89+
cmd.Use = "create [workspace]"
90+
cmd.Short = "Create a new external workspace"
91+
cmd.Middleware = serpent.Chain(
92+
cmd.Middleware,
93+
serpent.RequireNArgs(1),
94+
)
95+
96+
for i := range cmd.Options {
97+
if cmd.Options[i].Flag == "template" {
98+
cmd.Options[i].Required = true
99+
}
100+
}
101+
102+
return cmd
103+
}
104+
105+
// externalWorkspaceAgentInstructions prints the instructions for an external agent.
106+
func (r *RootCmd) externalWorkspaceAgentInstructions() *serpent.Command {
107+
client := new(codersdk.Client)
108+
formatter := cliui.NewOutputFormatter(
109+
cliui.ChangeFormatterData(cliui.TextFormat(), func(data any) (any, error) {
110+
agent, ok := data.(externalAgent)
111+
if !ok {
112+
return "", xerrors.Errorf("expected externalAgent, got %T", data)
113+
}
114+
115+
return formatExternalAgent(agent.WorkspaceName, []externalAgent{agent}), nil
116+
}),
117+
cliui.JSONFormat(),
118+
)
119+
120+
cmd := &serpent.Command{
121+
Use: "agent-instructions [user/]workspace[.agent]",
122+
Short: "Get the instructions for an external agent",
123+
Middleware: serpent.Chain(r.InitClient(client), serpent.RequireNArgs(1)),
124+
Handler: func(inv *serpent.Invocation) error {
125+
workspace, workspaceAgent, _, err := getWorkspaceAndAgent(inv.Context(), inv, client, false, inv.Args[0])
126+
if err != nil {
127+
return xerrors.Errorf("find workspace and agent: %w", err)
128+
}
129+
130+
credentials, err := client.WorkspaceExternalAgentCredentials(inv.Context(), workspace.ID, workspaceAgent.Name)
131+
if err != nil {
132+
return xerrors.Errorf("get external agent token for agent %q: %w", workspaceAgent.Name, err)
133+
}
134+
135+
agentInfo := externalAgent{
136+
WorkspaceName: workspace.Name,
137+
AgentName: workspaceAgent.Name,
138+
AuthType: "token",
139+
AuthToken: credentials.AgentToken,
140+
InitScript: credentials.Command,
141+
}
142+
143+
out, err := formatter.Format(inv.Context(), agentInfo)
144+
if err != nil {
145+
return err
146+
}
147+
148+
_, err = fmt.Fprintln(inv.Stdout, out)
149+
return err
150+
},
151+
}
152+
153+
formatter.AttachOptions(&cmd.Options)
154+
return cmd
155+
}
156+
157+
func (r *RootCmd) externalWorkspaceList() *serpent.Command {
158+
var (
159+
filter cliui.WorkspaceFilter
160+
formatter = cliui.NewOutputFormatter(
161+
cliui.TableFormat(
162+
[]workspaceListRow{},
163+
[]string{
164+
"workspace",
165+
"template",
166+
"status",
167+
"healthy",
168+
"last built",
169+
"current version",
170+
"outdated",
171+
},
172+
),
173+
cliui.JSONFormat(),
174+
)
175+
)
176+
client := new(codersdk.Client)
177+
cmd := &serpent.Command{
178+
Annotations: workspaceCommand,
179+
Use: "list",
180+
Short: "List external workspaces",
181+
Aliases: []string{"ls"},
182+
Middleware: serpent.Chain(
183+
serpent.RequireNArgs(0),
184+
r.InitClient(client),
185+
),
186+
Handler: func(inv *serpent.Invocation) error {
187+
baseFilter := filter.Filter()
188+
189+
if baseFilter.FilterQuery == "" {
190+
baseFilter.FilterQuery = "has-external-agent:true"
191+
} else {
192+
baseFilter.FilterQuery += " has-external-agent:true"
193+
}
194+
195+
res, err := queryConvertWorkspaces(inv.Context(), client, baseFilter, workspaceListRowFromWorkspace)
196+
if err != nil {
197+
return err
198+
}
199+
200+
if len(res) == 0 && formatter.FormatID() != cliui.JSONFormat().ID() {
201+
pretty.Fprintf(inv.Stderr, cliui.DefaultStyles.Prompt, "No workspaces found! Create one:\n")
202+
_, _ = fmt.Fprintln(inv.Stderr)
203+
_, _ = fmt.Fprintln(inv.Stderr, " "+pretty.Sprint(cliui.DefaultStyles.Code, "coder external-workspaces create <name>"))
204+
_, _ = fmt.Fprintln(inv.Stderr)
205+
return nil
206+
}
207+
208+
out, err := formatter.Format(inv.Context(), res)
209+
if err != nil {
210+
return err
211+
}
212+
213+
_, err = fmt.Fprintln(inv.Stdout, out)
214+
return err
215+
},
216+
}
217+
filter.AttachOptions(&cmd.Options)
218+
formatter.AttachOptions(&cmd.Options)
219+
return cmd
220+
}
221+
222+
// fetchExternalAgents fetches the external agents for a workspace.
223+
func fetchExternalAgents(inv *serpent.Invocation, client *codersdk.Client, workspace codersdk.Workspace, resources []codersdk.WorkspaceResource) ([]externalAgent, error) {
224+
if len(resources) == 0 {
225+
return nil, xerrors.Errorf("no resources found for workspace")
226+
}
227+
228+
var externalAgents []externalAgent
229+
230+
for _, resource := range resources {
231+
if resource.Type != "coder_external_agent" || len(resource.Agents) == 0 {
232+
continue
233+
}
234+
235+
agent := resource.Agents[0]
236+
credentials, err := client.WorkspaceExternalAgentCredentials(inv.Context(), workspace.ID, agent.Name)
237+
if err != nil {
238+
return nil, xerrors.Errorf("get external agent token for agent %q: %w", agent.Name, err)
239+
}
240+
241+
externalAgents = append(externalAgents, externalAgent{
242+
AgentName: agent.Name,
243+
AuthType: "token",
244+
AuthToken: credentials.AgentToken,
245+
InitScript: credentials.Command,
246+
})
247+
}
248+
249+
return externalAgents, nil
250+
}
251+
252+
// formatExternalAgent formats the instructions for an external agent.
253+
func formatExternalAgent(workspaceName string, externalAgents []externalAgent) string {
254+
var output strings.Builder
255+
_, _ = output.WriteString(fmt.Sprintf("\nPlease run the following commands to attach external agent to the workspace %s:\n\n", cliui.Keyword(workspaceName)))
256+
257+
for i, agent := range externalAgents {
258+
if len(externalAgents) > 1 {
259+
_, _ = output.WriteString(fmt.Sprintf("For agent %s:\n", cliui.Keyword(agent.AgentName)))
260+
}
261+
262+
_, _ = output.WriteString(fmt.Sprintf("%s\n", pretty.Sprint(cliui.DefaultStyles.Code, fmt.Sprintf("export CODER_AGENT_TOKEN=%s", agent.AuthToken))))
263+
_, _ = output.WriteString(fmt.Sprintf("%s\n", pretty.Sprint(cliui.DefaultStyles.Code, fmt.Sprintf("curl -fsSL %s | sh", agent.InitScript))))
264+
265+
if i < len(externalAgents)-1 {
266+
_, _ = output.WriteString("\n")
267+
}
268+
}
269+
270+
return output.String()
271+
}

0 commit comments

Comments
 (0)