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