Skip to content

feat: coder-attach: add support for external workspaces #19178

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 34 commits into from
Closed
Show file tree
Hide file tree
Changes from 12 commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
3ea541e
Add attach command and API endpoints for init-script and external age…
kacpersaw Jul 23, 2025
4818df1
add has_external_agents column to template_versions table
kacpersaw Jul 24, 2025
4bfdb83
add external workspace creation and agent instruction commands to cli
kacpersaw Jul 28, 2025
1044051
add has_external_agent to workspace builds
kacpersaw Jul 29, 2025
fd2458b
add list command for external workspaces
kacpersaw Jul 30, 2025
0c39f50
add AgentExternal component to display external agent connection deta…
kacpersaw Jul 30, 2025
23e555a
Merge remote-tracking branch 'origin/main' into kacpersaw/feat-coder-…
kacpersaw Jul 31, 2025
f9f5be1
add tests
kacpersaw Jul 31, 2025
e281f0e
Delete coder attach golden
kacpersaw Aug 5, 2025
d77522d
Hide agent apps when connecting & is external agent
kacpersaw Aug 5, 2025
451c806
Merge remote-tracking branch 'origin/main' into kacpersaw/feat-coder-…
kacpersaw Aug 5, 2025
f9274fe
Reformat code
kacpersaw Aug 5, 2025
00b6f26
Merge remote-tracking branch 'origin/main' into kacpersaw/feat-coder-…
kacpersaw Aug 6, 2025
c019a31
bump provisionerd proto version to v1.9
kacpersaw Aug 6, 2025
7d07857
Add beforeCreate and afterCreate to create handler, apply review sugg…
kacpersaw Aug 6, 2025
c462a69
Refactor init-script endpoint to use path params instead of query params
kacpersaw Aug 6, 2025
2d2dfec
Refactor init-script endpoint, apply review suggestions for db
kacpersaw Aug 6, 2025
387fc04
Apply FE review suggestions
kacpersaw Aug 6, 2025
c2588ea
Return 404 if workspace agent is authenticated through instance id
kacpersaw Aug 6, 2025
33dd778
Merge UpdateTemplateVersionAITaskByJobID and UpdateTemplateVersionExt…
kacpersaw Aug 7, 2025
c413479
update external agent credentials to include command in response
kacpersaw Aug 7, 2025
3c1d694
Bump terraform-provider-coder to v2.10.0
kacpersaw Aug 8, 2025
73acd0f
Merge remote-tracking branch 'origin/main' into kacpersaw/feat-coder-…
kacpersaw Aug 8, 2025
7cc6861
Regenerate sql
kacpersaw Aug 8, 2025
da68c20
Fix lint & tests
kacpersaw Aug 8, 2025
2e24741
Regenerate dump.sql
kacpersaw Aug 8, 2025
682ea60
Fix provision test
kacpersaw Aug 8, 2025
51967a5
update external agent credentials summary and adjust authorization ch…
kacpersaw Aug 8, 2025
f060324
merge UpdateWorkspaceBuildAITaskByID with UpdateWorkspaceBuildExterna…
kacpersaw Aug 8, 2025
ff6e8fa
Apply suggestions from code review
kacpersaw Aug 8, 2025
e2a7182
Apply review suggestions
kacpersaw Aug 8, 2025
141bc54
Merge branch 'main' into kacpersaw/feat-coder-attach
kacpersaw Aug 8, 2025
a75c1f4
make gen
kacpersaw Aug 8, 2025
22f2c00
Merge remote-tracking branch 'origin/main' into kacpersaw/feat-coder-…
kacpersaw Aug 11, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
326 changes: 326 additions & 0 deletions cli/external_workspaces.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,326 @@
package cli
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: Maybe this should be externalworkspaces.go too


import (
"fmt"
"strings"

"golang.org/x/xerrors"

"github.com/google/uuid"

"github.com/coder/coder/v2/cli/cliui"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/pretty"
"github.com/coder/serpent"
)

type externalAgent struct {
AgentName string `json:"-"`
AuthType string `json:"auth_type"`
AuthToken string `json:"auth_token"`
InitScript string `json:"init_script"`
}

func (r *RootCmd) externalWorkspaces() *serpent.Command {
orgContext := NewOrganizationContext()

cmd := &serpent.Command{
Use: "external-workspaces [subcommand]",
Short: "External workspace related commands",
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
Short: "External workspace related commands",
Short: "Create or manage external workspaces",

Handler: func(inv *serpent.Invocation) error {
return inv.Command.HelpHandler(inv)
},
Children: []*serpent.Command{
r.externalWorkspaceCreate(),
r.externalWorkspaceAgentInstructions(),
r.externalWorkspaceList(),
},
}

orgContext.AttachOptions(cmd)
return cmd
}

// externalWorkspaceCreate extends `coder create` to create an external workspace.
func (r *RootCmd) externalWorkspaceCreate() *serpent.Command {
var (
orgContext = NewOrganizationContext()
client = new(codersdk.Client)
)

cmd := r.create()
cmd.Use = "create [workspace]"
cmd.Short = "Create a new external workspace"
cmd.Middleware = serpent.Chain(
cmd.Middleware,
r.InitClient(client),
serpent.RequireNArgs(1),
)

createHandler := cmd.Handler
cmd.Handler = func(inv *serpent.Invocation) error {
workspaceName := inv.Args[0]
templateVersion := inv.ParsedFlags().Lookup("template-version")
templateName := inv.ParsedFlags().Lookup("template")
if templateName == nil || templateName.Value.String() == "" {
return xerrors.Errorf("template name is required for external workspace creation. Use --template=<template_name>")
}

organization, err := orgContext.Selected(inv, client)
if err != nil {
return xerrors.Errorf("get current organization: %w", err)
}

template, err := client.TemplateByName(inv.Context(), organization.ID, templateName.Value.String())
if err != nil {
return xerrors.Errorf("get template by name: %w", err)
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A lot of these queries are going to be repeated by the original handler, could we change the createHandler function into a model where the queries get run once, then a "validate" function that gets swapped for coder create or coder external-workspaces create gets executed (and all the queried objects get passed in), then the workspace gets created.

workspace, workspaceBuild, err := createHandler(ctx, inv, createOptions{
  beforeCreate: func(ctx context.Context, template codersdk.Template, ...) error {},
})


var resources []codersdk.WorkspaceResource
var templateVersionID uuid.UUID
if templateVersion == nil || templateVersion.Value.String() == "" {
templateVersionID = template.ActiveVersionID
} else {
version, err := client.TemplateVersionByName(inv.Context(), template.ID, templateVersion.Value.String())
if err != nil {
return xerrors.Errorf("get template version by name: %w", err)
}
templateVersionID = version.ID
}

resources, err = client.TemplateVersionResources(inv.Context(), templateVersionID)
if err != nil {
return xerrors.Errorf("get template version resources: %w", err)
}
if len(resources) == 0 {
return xerrors.Errorf("no resources found for template version %q", templateVersion.Value.String())
}

var hasExternalAgent bool
for _, resource := range resources {
if resource.Type == "coder_external_agent" {
hasExternalAgent = true
break
}
}

if !hasExternalAgent {
return xerrors.Errorf("template version %q does not have an external agent. Only templates with external agents can be used for external workspace creation", templateVersion.Value.String())
}

err = createHandler(inv)
if err != nil {
return err
}

workspace, err := client.WorkspaceByOwnerAndName(inv.Context(), codersdk.Me, workspaceName, codersdk.WorkspaceOptions{})
if err != nil {
return xerrors.Errorf("get workspace by name: %w", err)
}

externalAgents, err := fetchExternalAgents(inv, client, workspace, workspace.LatestBuild.Resources)
if err != nil {
return xerrors.Errorf("fetch external agents: %w", err)
}

return printExternalAgents(inv, workspace.Name, externalAgents)
}
return cmd
}

// externalWorkspaceAgentInstructions prints the instructions for an external agent.
func (r *RootCmd) externalWorkspaceAgentInstructions() *serpent.Command {
client := new(codersdk.Client)
formatter := cliui.NewOutputFormatter(
cliui.ChangeFormatterData(cliui.TextFormat(), func(data any) (any, error) {
agent, ok := data.(externalAgent)
if !ok {
return "", xerrors.Errorf("expected externalAgent, got %T", data)
}

var output strings.Builder
_, _ = output.WriteString(fmt.Sprintf("Please run the following commands to attach agent %s:\n", cliui.Keyword(agent.AgentName)))
_, _ = output.WriteString(fmt.Sprintf("%s\n", pretty.Sprint(cliui.DefaultStyles.Code, fmt.Sprintf("export CODER_AGENT_TOKEN=%s", agent.AuthToken))))
_, _ = output.WriteString(pretty.Sprint(cliui.DefaultStyles.Code, fmt.Sprintf("curl -fsSL %s | sh", agent.InitScript)))

return output.String(), nil
}),
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This chunk of code seems to be used multiple times, can you not reuse printExternalAgents()?

cliui.JSONFormat(),
)

cmd := &serpent.Command{
Use: "agent-instructions [workspace name] [agent name]",
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Other commands use [user/]workspace[.agent] syntax for selecting an agent, defaulting to the only agent in the workspace if the agent is unspecified and there's only one. I believe there's a helper function in cli to resolve these for you.

Short: "Get the instructions for an external agent",
Middleware: serpent.Chain(r.InitClient(client), serpent.RequireNArgs(2)),
Handler: func(inv *serpent.Invocation) error {
workspaceName := inv.Args[0]
agentName := inv.Args[1]

workspace, err := client.WorkspaceByOwnerAndName(inv.Context(), codersdk.Me, workspaceName, codersdk.WorkspaceOptions{})
if err != nil {
return xerrors.Errorf("get workspace by name: %w", err)
}

credential, err := client.WorkspaceExternalAgentCredential(inv.Context(), workspace.ID, agentName)
if err != nil {
return xerrors.Errorf("get external agent token for agent %q: %w", agentName, err)
}

var agent codersdk.WorkspaceAgent
for _, resource := range workspace.LatestBuild.Resources {
for _, a := range resource.Agents {
if a.Name == agentName {
agent = a
break
}
}
if agent.ID != uuid.Nil {
break
}
}

initScriptURL := fmt.Sprintf("%s/api/v2/init-script", client.URL)
if agent.OperatingSystem != "linux" || agent.Architecture != "amd64" {
initScriptURL = fmt.Sprintf("%s/api/v2/init-script?os=%s&arch=%s", client.URL, agent.OperatingSystem, agent.Architecture)
}

agentInfo := externalAgent{
AgentName: agentName,
AuthType: "token",
AuthToken: credential.AgentToken,
InitScript: initScriptURL,
}

out, err := formatter.Format(inv.Context(), agentInfo)
if err != nil {
return err
}

_, err = fmt.Fprintln(inv.Stdout, out)
return err
},
}

formatter.AttachOptions(&cmd.Options)
return cmd
}

func (r *RootCmd) externalWorkspaceList() *serpent.Command {
var (
filter cliui.WorkspaceFilter
formatter = cliui.NewOutputFormatter(
cliui.TableFormat(
[]workspaceListRow{},
[]string{
"workspace",
"template",
"status",
"healthy",
"last built",
"current version",
"outdated",
},
),
cliui.JSONFormat(),
)
)
client := new(codersdk.Client)
cmd := &serpent.Command{
Annotations: workspaceCommand,
Use: "list",
Short: "List external workspaces",
Aliases: []string{"ls"},
Middleware: serpent.Chain(
serpent.RequireNArgs(0),
r.InitClient(client),
),
Handler: func(inv *serpent.Invocation) error {
baseFilter := filter.Filter()

if baseFilter.FilterQuery == "" {
baseFilter.FilterQuery = "has-external-agent:true"
} else {
baseFilter.FilterQuery += " has-external-agent:true"
}

res, err := queryConvertWorkspaces(inv.Context(), client, baseFilter, workspaceListRowFromWorkspace)
if err != nil {
return err
}

if len(res) == 0 && formatter.FormatID() != cliui.JSONFormat().ID() {
pretty.Fprintf(inv.Stderr, cliui.DefaultStyles.Prompt, "No workspaces found! Create one:\n")
_, _ = fmt.Fprintln(inv.Stderr)
_, _ = fmt.Fprintln(inv.Stderr, " "+pretty.Sprint(cliui.DefaultStyles.Code, "coder external-workspaces create <name>"))
_, _ = fmt.Fprintln(inv.Stderr)
return nil
}

out, err := formatter.Format(inv.Context(), res)
if err != nil {
return err
}

_, err = fmt.Fprintln(inv.Stdout, out)
return err
},
}
filter.AttachOptions(&cmd.Options)
formatter.AttachOptions(&cmd.Options)
return cmd
}

// fetchExternalAgents fetches the external agents for a workspace.
func fetchExternalAgents(inv *serpent.Invocation, client *codersdk.Client, workspace codersdk.Workspace, resources []codersdk.WorkspaceResource) ([]externalAgent, error) {
if len(resources) == 0 {
return nil, xerrors.Errorf("no resources found for workspace")
}

var externalAgents []externalAgent

for _, resource := range resources {
if resource.Type != "coder_external_agent" || len(resource.Agents) == 0 {
continue
}

agent := resource.Agents[0]
credential, err := client.WorkspaceExternalAgentCredential(inv.Context(), workspace.ID, agent.Name)
if err != nil {
return nil, xerrors.Errorf("get external agent token for agent %q: %w", agent.Name, err)
}

initScriptURL := fmt.Sprintf("%s/api/v2/init-script", client.URL)
if agent.OperatingSystem != "linux" || agent.Architecture != "amd64" {
initScriptURL = fmt.Sprintf("%s/api/v2/init-script?os=%s&arch=%s", client.URL, agent.OperatingSystem, agent.Architecture)
}

externalAgents = append(externalAgents, externalAgent{
AgentName: agent.Name,
AuthType: "token",
AuthToken: credential.AgentToken,
InitScript: initScriptURL,
})
}

return externalAgents, nil
}

// printExternalAgents prints the instructions for an external agent.
func printExternalAgents(inv *serpent.Invocation, workspaceName string, externalAgents []externalAgent) error {
_, _ = fmt.Fprintf(inv.Stdout, "\nPlease run the following commands to attach external agent to the workspace %s:\n\n", cliui.Keyword(workspaceName))

for i, agent := range externalAgents {
if len(externalAgents) > 1 {
_, _ = fmt.Fprintf(inv.Stdout, "For agent %s:\n", cliui.Keyword(agent.AgentName))
}

_, _ = fmt.Fprintf(inv.Stdout, "%s\n", pretty.Sprint(cliui.DefaultStyles.Code, fmt.Sprintf("export CODER_AGENT_TOKEN=%s", agent.AuthToken)))
_, _ = fmt.Fprintf(inv.Stdout, "%s\n", pretty.Sprint(cliui.DefaultStyles.Code, fmt.Sprintf("curl -fsSL %s | sh", agent.InitScript)))

if i < len(externalAgents)-1 {
_, _ = fmt.Fprintf(inv.Stdout, "\n")
}
}

return nil
}
Loading
Loading