Skip to content

Commit 0f07973

Browse files
committed
feat(cli): add external-workspaces CLI command to create, list and manage external workspaces
1 parent b8ed233 commit 0f07973

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)
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy