Skip to content

Commit 3ea541e

Browse files
committed
Add attach command and API endpoints for init-script and external agent credential
1 parent f41275e commit 3ea541e

File tree

21 files changed

+821
-30
lines changed

21 files changed

+821
-30
lines changed

cli/attach.go

Lines changed: 315 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,315 @@
1+
package cli
2+
3+
import (
4+
"fmt"
5+
"slices"
6+
"strings"
7+
"time"
8+
9+
"github.com/google/uuid"
10+
"golang.org/x/xerrors"
11+
12+
"github.com/coder/coder/v2/cli/cliui"
13+
"github.com/coder/coder/v2/cli/cliutil"
14+
"github.com/coder/coder/v2/coderd/util/slice"
15+
"github.com/coder/coder/v2/codersdk"
16+
"github.com/coder/pretty"
17+
"github.com/coder/serpent"
18+
)
19+
20+
func (r *RootCmd) attach() *serpent.Command {
21+
var (
22+
templateName string
23+
templateVersion string
24+
workspaceName string
25+
26+
parameterFlags workspaceParameterFlags
27+
// Organization context is only required if more than 1 template
28+
// shares the same name across multiple organizations.
29+
orgContext = NewOrganizationContext()
30+
)
31+
client := new(codersdk.Client)
32+
cmd := &serpent.Command{
33+
Annotations: workspaceCommand,
34+
Use: "attach [workspace]",
35+
Short: "Create a workspace and attach an external agent to it",
36+
Long: FormatExamples(
37+
Example{
38+
Description: "Attach an external agent to a workspace",
39+
Command: "coder attach my-workspace --template externally-managed-workspace --output text",
40+
},
41+
),
42+
Middleware: serpent.Chain(r.InitClient(client)),
43+
Handler: func(inv *serpent.Invocation) error {
44+
var err error
45+
workspaceOwner := codersdk.Me
46+
if len(inv.Args) >= 1 {
47+
workspaceOwner, workspaceName, err = splitNamedWorkspace(inv.Args[0])
48+
if err != nil {
49+
return err
50+
}
51+
}
52+
53+
if workspaceName == "" {
54+
workspaceName, err = cliui.Prompt(inv, cliui.PromptOptions{
55+
Text: "Specify a name for your workspace:",
56+
Validate: func(workspaceName string) error {
57+
err = codersdk.NameValid(workspaceName)
58+
if err != nil {
59+
return xerrors.Errorf("workspace name %q is invalid: %w", workspaceName, err)
60+
}
61+
_, err = client.WorkspaceByOwnerAndName(inv.Context(), workspaceOwner, workspaceName, codersdk.WorkspaceOptions{})
62+
if err == nil {
63+
return xerrors.Errorf("a workspace already exists named %q", workspaceName)
64+
}
65+
return nil
66+
},
67+
})
68+
if err != nil {
69+
return err
70+
}
71+
}
72+
err = codersdk.NameValid(workspaceName)
73+
if err != nil {
74+
return xerrors.Errorf("workspace name %q is invalid: %w", workspaceName, err)
75+
}
76+
77+
if workspace, err := client.WorkspaceByOwnerAndName(inv.Context(), workspaceOwner, workspaceName, codersdk.WorkspaceOptions{}); err == nil {
78+
return externalAgentDetails(inv, client, workspace, workspace.LatestBuild.Resources)
79+
}
80+
81+
// If workspace doesn't exist, create it
82+
var template codersdk.Template
83+
var templateVersionID uuid.UUID
84+
switch {
85+
case templateName == "":
86+
_, _ = fmt.Fprintln(inv.Stdout, pretty.Sprint(cliui.DefaultStyles.Wrap, "Select a template below to preview the provisioned infrastructure:"))
87+
88+
templates, err := client.Templates(inv.Context(), codersdk.TemplateFilter{})
89+
if err != nil {
90+
return err
91+
}
92+
93+
slices.SortFunc(templates, func(a, b codersdk.Template) int {
94+
return slice.Descending(a.ActiveUserCount, b.ActiveUserCount)
95+
})
96+
97+
templateNames := make([]string, 0, len(templates))
98+
templateByName := make(map[string]codersdk.Template, len(templates))
99+
100+
// If more than 1 organization exists in the list of templates,
101+
// then include the organization name in the select options.
102+
uniqueOrganizations := make(map[uuid.UUID]bool)
103+
for _, template := range templates {
104+
uniqueOrganizations[template.OrganizationID] = true
105+
}
106+
107+
for _, template := range templates {
108+
templateName := template.Name
109+
if len(uniqueOrganizations) > 1 {
110+
templateName += cliui.Placeholder(
111+
fmt.Sprintf(
112+
" (%s)",
113+
template.OrganizationName,
114+
),
115+
)
116+
}
117+
118+
if template.ActiveUserCount > 0 {
119+
templateName += cliui.Placeholder(
120+
fmt.Sprintf(
121+
" used by %s",
122+
formatActiveDevelopers(template.ActiveUserCount),
123+
),
124+
)
125+
}
126+
127+
templateNames = append(templateNames, templateName)
128+
templateByName[templateName] = template
129+
}
130+
131+
// Move the cursor up a single line for nicer display!
132+
option, err := cliui.Select(inv, cliui.SelectOptions{
133+
Options: templateNames,
134+
HideSearch: true,
135+
})
136+
if err != nil {
137+
return err
138+
}
139+
140+
template = templateByName[option]
141+
templateVersionID = template.ActiveVersionID
142+
default:
143+
templates, err := client.Templates(inv.Context(), codersdk.TemplateFilter{
144+
ExactName: templateName,
145+
})
146+
if err != nil {
147+
return xerrors.Errorf("get template by name: %w", err)
148+
}
149+
if len(templates) == 0 {
150+
return xerrors.Errorf("no template found with the name %q", templateName)
151+
}
152+
153+
if len(templates) > 1 {
154+
templateOrgs := []string{}
155+
for _, tpl := range templates {
156+
templateOrgs = append(templateOrgs, tpl.OrganizationName)
157+
}
158+
159+
selectedOrg, err := orgContext.Selected(inv, client)
160+
if err != nil {
161+
return xerrors.Errorf("multiple templates found with the name %q, use `--org=<organization_name>` to specify which template by that name to use. Organizations available: %s", templateName, strings.Join(templateOrgs, ", "))
162+
}
163+
164+
index := slices.IndexFunc(templates, func(i codersdk.Template) bool {
165+
return i.OrganizationID == selectedOrg.ID
166+
})
167+
if index == -1 {
168+
return xerrors.Errorf("no templates found with the name %q in the organization %q. Templates by that name exist in organizations: %s. Use --org=<organization_name> to select one.", templateName, selectedOrg.Name, strings.Join(templateOrgs, ", "))
169+
}
170+
171+
// remake the list with the only template selected
172+
templates = []codersdk.Template{templates[index]}
173+
}
174+
175+
template = templates[0]
176+
templateVersionID = template.ActiveVersionID
177+
}
178+
179+
if len(templateVersion) > 0 {
180+
version, err := client.TemplateVersionByName(inv.Context(), template.ID, templateVersion)
181+
if err != nil {
182+
return xerrors.Errorf("get template version by name: %w", err)
183+
}
184+
templateVersionID = version.ID
185+
}
186+
187+
// If the user specified an organization via a flag or env var, the template **must**
188+
// be in that organization. Otherwise, we should throw an error.
189+
orgValue, orgValueSource := orgContext.ValueSource(inv)
190+
if orgValue != "" && !(orgValueSource == serpent.ValueSourceDefault || orgValueSource == serpent.ValueSourceNone) {
191+
selectedOrg, err := orgContext.Selected(inv, client)
192+
if err != nil {
193+
return err
194+
}
195+
196+
if template.OrganizationID != selectedOrg.ID {
197+
orgNameFormat := "'--org=%q'"
198+
if orgValueSource == serpent.ValueSourceEnv {
199+
orgNameFormat = "CODER_ORGANIZATION=%q"
200+
}
201+
202+
return xerrors.Errorf("template is in organization %q, but %s was specified. Use %s to use this template",
203+
template.OrganizationName,
204+
fmt.Sprintf(orgNameFormat, selectedOrg.Name),
205+
fmt.Sprintf(orgNameFormat, template.OrganizationName),
206+
)
207+
}
208+
}
209+
210+
cliBuildParameters, err := asWorkspaceBuildParameters(parameterFlags.richParameters)
211+
if err != nil {
212+
return xerrors.Errorf("can't parse given parameter values: %w", err)
213+
}
214+
215+
cliBuildParameterDefaults, err := asWorkspaceBuildParameters(parameterFlags.richParameterDefaults)
216+
if err != nil {
217+
return xerrors.Errorf("can't parse given parameter defaults: %w", err)
218+
}
219+
220+
richParameters, resources, err := prepWorkspaceBuild(inv, client, prepWorkspaceBuildArgs{
221+
Action: WorkspaceCreate,
222+
TemplateVersionID: templateVersionID,
223+
NewWorkspaceName: workspaceName,
224+
225+
RichParameterFile: parameterFlags.richParameterFile,
226+
RichParameters: cliBuildParameters,
227+
RichParameterDefaults: cliBuildParameterDefaults,
228+
})
229+
if err != nil {
230+
return xerrors.Errorf("prepare build: %w", err)
231+
}
232+
233+
_, err = cliui.Prompt(inv, cliui.PromptOptions{
234+
Text: "Confirm create?",
235+
IsConfirm: true,
236+
})
237+
if err != nil {
238+
return err
239+
}
240+
241+
workspace, err := client.CreateUserWorkspace(inv.Context(), workspaceOwner, codersdk.CreateWorkspaceRequest{
242+
TemplateVersionID: templateVersionID,
243+
Name: workspaceName,
244+
RichParameterValues: richParameters,
245+
})
246+
if err != nil {
247+
return xerrors.Errorf("create workspace: %w", err)
248+
}
249+
250+
cliutil.WarnMatchedProvisioners(inv.Stderr, workspace.LatestBuild.MatchedProvisioners, workspace.LatestBuild.Job)
251+
252+
err = cliui.WorkspaceBuild(inv.Context(), inv.Stdout, client, workspace.LatestBuild.ID)
253+
if err != nil {
254+
return xerrors.Errorf("watch build: %w", err)
255+
}
256+
257+
_, _ = fmt.Fprintf(
258+
inv.Stdout,
259+
"\nThe %s workspace has been created at %s!\n\n",
260+
cliui.Keyword(workspace.Name),
261+
cliui.Timestamp(time.Now()),
262+
)
263+
264+
return externalAgentDetails(inv, client, workspace, resources)
265+
},
266+
}
267+
268+
cmd.Options = serpent.OptionSet{
269+
serpent.Option{
270+
Flag: "template",
271+
FlagShorthand: "t",
272+
Env: "CODER_TEMPLATE_NAME",
273+
Description: "Specify a template name.",
274+
Value: serpent.StringOf(&templateName),
275+
},
276+
serpent.Option{
277+
Flag: "template-version",
278+
Env: "CODER_TEMPLATE_VERSION",
279+
Description: "Specify a template version name.",
280+
Value: serpent.StringOf(&templateVersion),
281+
},
282+
cliui.SkipPromptOption(),
283+
}
284+
cmd.Options = append(cmd.Options, parameterFlags.cliParameters()...)
285+
cmd.Options = append(cmd.Options, parameterFlags.cliParameterDefaults()...)
286+
orgContext.AttachOptions(cmd)
287+
return cmd
288+
}
289+
290+
func externalAgentDetails(inv *serpent.Invocation, client *codersdk.Client, workspace codersdk.Workspace, resources []codersdk.WorkspaceResource) error {
291+
if len(resources) == 0 {
292+
return xerrors.Errorf("no resources found for workspace")
293+
}
294+
295+
for _, resource := range resources {
296+
if resource.Type == "coder_external_agent" {
297+
agent := resource.Agents[0]
298+
credential, err := client.WorkspaceExternalAgentCredential(inv.Context(), workspace.ID, agent.Name)
299+
if err != nil {
300+
return xerrors.Errorf("get external agent token: %w", err)
301+
}
302+
303+
initScriptURL := fmt.Sprintf("%s/api/v2/init-script", client.URL)
304+
if agent.OperatingSystem != "linux" || agent.Architecture != "amd64" {
305+
initScriptURL = fmt.Sprintf("%s/api/v2/init-script?os=%s&arch=%s", client.URL, agent.OperatingSystem, agent.Architecture)
306+
}
307+
308+
_, _ = fmt.Fprintf(inv.Stdout, "Please run the following commands to attach an agent to the workspace %s:\n", cliui.Keyword(workspace.Name))
309+
_, _ = fmt.Fprintf(inv.Stdout, "%s\n", pretty.Sprint(cliui.DefaultStyles.Code, fmt.Sprintf("export CODER_AGENT_TOKEN=%s", credential.AgentToken)))
310+
_, _ = fmt.Fprintf(inv.Stdout, "%s\n", pretty.Sprint(cliui.DefaultStyles.Code, fmt.Sprintf("curl -fsSL %s | sh", initScriptURL)))
311+
}
312+
}
313+
314+
return nil
315+
}

0 commit comments

Comments
 (0)