-
Notifications
You must be signed in to change notification settings - Fork 963
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
Closed
Changes from all 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 4818df1
add has_external_agents column to template_versions table
kacpersaw 4bfdb83
add external workspace creation and agent instruction commands to cli
kacpersaw 1044051
add has_external_agent to workspace builds
kacpersaw fd2458b
add list command for external workspaces
kacpersaw 0c39f50
add AgentExternal component to display external agent connection deta…
kacpersaw 23e555a
Merge remote-tracking branch 'origin/main' into kacpersaw/feat-coder-…
kacpersaw f9f5be1
add tests
kacpersaw e281f0e
Delete coder attach golden
kacpersaw d77522d
Hide agent apps when connecting & is external agent
kacpersaw 451c806
Merge remote-tracking branch 'origin/main' into kacpersaw/feat-coder-…
kacpersaw f9274fe
Reformat code
kacpersaw 00b6f26
Merge remote-tracking branch 'origin/main' into kacpersaw/feat-coder-…
kacpersaw c019a31
bump provisionerd proto version to v1.9
kacpersaw 7d07857
Add beforeCreate and afterCreate to create handler, apply review sugg…
kacpersaw c462a69
Refactor init-script endpoint to use path params instead of query params
kacpersaw 2d2dfec
Refactor init-script endpoint, apply review suggestions for db
kacpersaw 387fc04
Apply FE review suggestions
kacpersaw c2588ea
Return 404 if workspace agent is authenticated through instance id
kacpersaw 33dd778
Merge UpdateTemplateVersionAITaskByJobID and UpdateTemplateVersionExt…
kacpersaw c413479
update external agent credentials to include command in response
kacpersaw 3c1d694
Bump terraform-provider-coder to v2.10.0
kacpersaw 73acd0f
Merge remote-tracking branch 'origin/main' into kacpersaw/feat-coder-…
kacpersaw 7cc6861
Regenerate sql
kacpersaw da68c20
Fix lint & tests
kacpersaw 2e24741
Regenerate dump.sql
kacpersaw 682ea60
Fix provision test
kacpersaw 51967a5
update external agent credentials summary and adjust authorization ch…
kacpersaw f060324
merge UpdateWorkspaceBuildAITaskByID with UpdateWorkspaceBuildExterna…
kacpersaw ff6e8fa
Apply suggestions from code review
kacpersaw e2a7182
Apply review suggestions
kacpersaw 141bc54
Merge branch 'main' into kacpersaw/feat-coder-attach
kacpersaw a75c1f4
make gen
kacpersaw 22f2c00
Merge remote-tracking branch 'origin/main' into kacpersaw/feat-coder-…
kacpersaw File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,271 @@ | ||
package cli | ||
|
||
import ( | ||
"context" | ||
"fmt" | ||
"strings" | ||
|
||
"github.com/google/uuid" | ||
"golang.org/x/xerrors" | ||
|
||
"github.com/coder/coder/v2/cli/cliui" | ||
"github.com/coder/coder/v2/codersdk" | ||
"github.com/coder/pretty" | ||
"github.com/coder/serpent" | ||
) | ||
|
||
type externalAgent struct { | ||
WorkspaceName string `json:"-"` | ||
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: "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 | ||
} | ||
Comment on lines
+25
to
+43
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Is this functionality available in OSS as well? |
||
|
||
// externalWorkspaceCreate extends `coder create` to create an external workspace. | ||
func (r *RootCmd) externalWorkspaceCreate() *serpent.Command { | ||
opts := createOptions{ | ||
beforeCreate: func(ctx context.Context, client *codersdk.Client, _ codersdk.Template, templateVersionID uuid.UUID) error { | ||
resources, err := client.TemplateVersionResources(ctx, 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", templateVersionID) | ||
} | ||
|
||
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", templateVersionID) | ||
} | ||
|
||
return nil | ||
}, | ||
afterCreate: func(ctx context.Context, inv *serpent.Invocation, client *codersdk.Client, workspace codersdk.Workspace) error { | ||
workspace, err := client.WorkspaceByOwnerAndName(ctx, codersdk.Me, workspace.Name, 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) | ||
} | ||
|
||
formatted := formatExternalAgent(workspace.Name, externalAgents) | ||
_, err = fmt.Fprintln(inv.Stdout, formatted) | ||
return err | ||
}, | ||
} | ||
|
||
cmd := r.create(opts) | ||
cmd.Use = "create [workspace]" | ||
cmd.Short = "Create a new external workspace" | ||
cmd.Middleware = serpent.Chain( | ||
cmd.Middleware, | ||
serpent.RequireNArgs(1), | ||
) | ||
|
||
for i := range cmd.Options { | ||
if cmd.Options[i].Flag == "template" { | ||
cmd.Options[i].Required = true | ||
} | ||
} | ||
|
||
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) | ||
} | ||
|
||
return formatExternalAgent(agent.WorkspaceName, []externalAgent{agent}), nil | ||
}), | ||
cliui.JSONFormat(), | ||
) | ||
|
||
cmd := &serpent.Command{ | ||
Use: "agent-instructions [user/]workspace[.agent]", | ||
Short: "Get the instructions for an external agent", | ||
Middleware: serpent.Chain(r.InitClient(client), serpent.RequireNArgs(1)), | ||
Handler: func(inv *serpent.Invocation) error { | ||
workspace, workspaceAgent, _, err := getWorkspaceAndAgent(inv.Context(), inv, client, false, inv.Args[0]) | ||
if err != nil { | ||
return xerrors.Errorf("find workspace and agent: %w", err) | ||
} | ||
|
||
credentials, err := client.WorkspaceExternalAgentCredentials(inv.Context(), workspace.ID, workspaceAgent.Name) | ||
if err != nil { | ||
return xerrors.Errorf("get external agent token for agent %q: %w", workspaceAgent.Name, err) | ||
} | ||
|
||
agentInfo := externalAgent{ | ||
WorkspaceName: workspace.Name, | ||
AgentName: workspaceAgent.Name, | ||
AuthType: "token", | ||
AuthToken: credentials.AgentToken, | ||
InitScript: credentials.Command, | ||
} | ||
|
||
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] | ||
credentials, err := client.WorkspaceExternalAgentCredentials(inv.Context(), workspace.ID, agent.Name) | ||
if err != nil { | ||
return nil, xerrors.Errorf("get external agent token for agent %q: %w", agent.Name, err) | ||
} | ||
|
||
externalAgents = append(externalAgents, externalAgent{ | ||
AgentName: agent.Name, | ||
AuthType: "token", | ||
AuthToken: credentials.AgentToken, | ||
InitScript: credentials.Command, | ||
}) | ||
} | ||
|
||
return externalAgents, nil | ||
} | ||
|
||
// formatExternalAgent formats the instructions for an external agent. | ||
func formatExternalAgent(workspaceName string, externalAgents []externalAgent) string { | ||
var output strings.Builder | ||
_, _ = output.WriteString(fmt.Sprintf("\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 { | ||
_, _ = output.WriteString(fmt.Sprintf("For 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(fmt.Sprintf("%s\n", pretty.Sprint(cliui.DefaultStyles.Code, fmt.Sprintf("curl -fsSL %s | sh", agent.InitScript)))) | ||
|
||
if i < len(externalAgents)-1 { | ||
_, _ = output.WriteString("\n") | ||
} | ||
} | ||
|
||
return output.String() | ||
} |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
👍 nice abstraction!