From 3a210e861d8bfbdbe062ff049be4f79c947f85f6 Mon Sep 17 00:00:00 2001 From: Kacper Sawicki Date: Mon, 11 Aug 2025 11:55:52 +0000 Subject: [PATCH 1/4] feat(cli): add external-workspaces CLI command to create, list and manage external workspaces --- cli/create.go | 22 +- cli/externalworkspaces.go | 271 ++++++++++ cli/externalworkspaces_test.go | 468 ++++++++++++++++++ cli/root.go | 3 +- cli/testdata/coder_--help.golden | 99 ++-- .../coder_external-workspaces_--help.golden | 18 + ...orkspaces_agent-instructions_--help.golden | 13 + ...r_external-workspaces_create_--help.golden | 56 +++ ...der_external-workspaces_list_--help.golden | 24 + docs/manifest.json | 20 + docs/reference/cli/external-workspaces.md | 29 ++ .../external-workspaces_agent-instructions.md | 21 + .../cli/external-workspaces_create.md | 128 +++++ .../reference/cli/external-workspaces_list.md | 51 ++ docs/reference/cli/index.md | 91 ++-- 15 files changed, 1219 insertions(+), 95 deletions(-) create mode 100644 cli/externalworkspaces.go create mode 100644 cli/externalworkspaces_test.go create mode 100644 cli/testdata/coder_external-workspaces_--help.golden create mode 100644 cli/testdata/coder_external-workspaces_agent-instructions_--help.golden create mode 100644 cli/testdata/coder_external-workspaces_create_--help.golden create mode 100644 cli/testdata/coder_external-workspaces_list_--help.golden create mode 100644 docs/reference/cli/external-workspaces.md create mode 100644 docs/reference/cli/external-workspaces_agent-instructions.md create mode 100644 docs/reference/cli/external-workspaces_create.md create mode 100644 docs/reference/cli/external-workspaces_list.md diff --git a/cli/create.go b/cli/create.go index 3f52e59e8ad90..db8253ddb5d03 100644 --- a/cli/create.go +++ b/cli/create.go @@ -29,7 +29,12 @@ const PresetNone = "none" var ErrNoPresetFound = xerrors.New("no preset found") -func (r *RootCmd) create() *serpent.Command { +type createOptions struct { + beforeCreate func(ctx context.Context, client *codersdk.Client, template codersdk.Template, templateVersionID uuid.UUID) error + afterCreate func(ctx context.Context, inv *serpent.Invocation, client *codersdk.Client, workspace codersdk.Workspace) error +} + +func (r *RootCmd) create(opts createOptions) *serpent.Command { var ( templateName string templateVersion string @@ -305,6 +310,13 @@ func (r *RootCmd) create() *serpent.Command { _, _ = fmt.Fprintf(inv.Stdout, "%s", cliui.Bold("No preset applied.")) } + if opts.beforeCreate != nil { + err = opts.beforeCreate(inv.Context(), client, template, templateVersionID) + if err != nil { + return xerrors.Errorf("before create: %w", err) + } + } + richParameters, err := prepWorkspaceBuild(inv, client, prepWorkspaceBuildArgs{ Action: WorkspaceCreate, TemplateVersionID: templateVersionID, @@ -366,6 +378,14 @@ func (r *RootCmd) create() *serpent.Command { cliui.Keyword(workspace.Name), cliui.Timestamp(time.Now()), ) + + if opts.afterCreate != nil { + err = opts.afterCreate(inv.Context(), inv, client, workspace) + if err != nil { + return err + } + } + return nil }, } diff --git a/cli/externalworkspaces.go b/cli/externalworkspaces.go new file mode 100644 index 0000000000000..5ea1d2d8c9410 --- /dev/null +++ b/cli/externalworkspaces.go @@ -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 +} + +// 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 ")) + _, _ = 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() +} diff --git a/cli/externalworkspaces_test.go b/cli/externalworkspaces_test.go new file mode 100644 index 0000000000000..cf89ec4c2cb0c --- /dev/null +++ b/cli/externalworkspaces_test.go @@ -0,0 +1,468 @@ +package cli_test + +import ( + "bytes" + "context" + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/coder/coder/v2/cli/clitest" + "github.com/coder/coder/v2/coderd/coderdtest" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/provisioner/echo" + "github.com/coder/coder/v2/provisionersdk/proto" + "github.com/coder/coder/v2/pty/ptytest" + "github.com/coder/coder/v2/testutil" +) + +// completeWithExternalAgent creates a template version with an external agent resource +func completeWithExternalAgent() *echo.Responses { + return &echo.Responses{ + Parse: echo.ParseComplete, + ProvisionPlan: []*proto.Response{ + { + Type: &proto.Response_Plan{ + Plan: &proto.PlanComplete{ + Resources: []*proto.Resource{ + { + Type: "coder_external_agent", + Name: "main", + Agents: []*proto.Agent{ + { + Name: "external-agent", + OperatingSystem: "linux", + Architecture: "amd64", + }, + }, + }, + }, + }, + }, + }, + }, + ProvisionApply: []*proto.Response{ + { + Type: &proto.Response_Apply{ + Apply: &proto.ApplyComplete{ + Resources: []*proto.Resource{ + { + Type: "coder_external_agent", + Name: "main", + Agents: []*proto.Agent{ + { + Name: "external-agent", + OperatingSystem: "linux", + Architecture: "amd64", + }, + }, + }, + }, + }, + }, + }, + }, + } +} + +// completeWithRegularAgent creates a template version with a regular agent (no external agent) +func completeWithRegularAgent() *echo.Responses { + return &echo.Responses{ + Parse: echo.ParseComplete, + ProvisionPlan: []*proto.Response{ + { + Type: &proto.Response_Plan{ + Plan: &proto.PlanComplete{ + Resources: []*proto.Resource{ + { + Type: "compute", + Name: "main", + Agents: []*proto.Agent{ + { + Name: "regular-agent", + OperatingSystem: "linux", + Architecture: "amd64", + }, + }, + }, + }, + }, + }, + }, + }, + ProvisionApply: []*proto.Response{ + { + Type: &proto.Response_Apply{ + Apply: &proto.ApplyComplete{ + Resources: []*proto.Resource{ + { + Type: "compute", + Name: "main", + Agents: []*proto.Agent{ + { + Name: "regular-agent", + OperatingSystem: "linux", + Architecture: "amd64", + }, + }, + }, + }, + }, + }, + }, + }, + } +} + +func TestExternalWorkspaces(t *testing.T) { + t.Parallel() + + t.Run("Create", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) + owner := coderdtest.CreateFirstUser(t, client) + member, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID) + version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, completeWithExternalAgent()) + coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) + template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID) + + args := []string{ + "external-workspaces", + "create", + "my-external-workspace", + "--template", template.Name, + } + inv, root := clitest.New(t, args...) + clitest.SetupConfig(t, member, root) + doneChan := make(chan struct{}) + pty := ptytest.New(t).Attach(inv) + go func() { + defer close(doneChan) + err := inv.Run() + assert.NoError(t, err) + }() + + // Expect the workspace creation confirmation + pty.ExpectMatch("coder_external_agent.main") + pty.ExpectMatch("external-agent (linux, amd64)") + pty.ExpectMatch("Confirm create") + pty.WriteLine("yes") + + // Expect the external agent instructions + pty.ExpectMatch("Please run the following commands to attach external agent") + pty.ExpectMatch("export CODER_AGENT_TOKEN=") + pty.ExpectMatch("curl -fsSL") + + <-doneChan + + // Verify the workspace was created + ws, err := member.WorkspaceByOwnerAndName(context.Background(), codersdk.Me, "my-external-workspace", codersdk.WorkspaceOptions{}) + require.NoError(t, err) + assert.Equal(t, template.Name, ws.TemplateName) + }) + + t.Run("CreateWithoutTemplate", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) + owner := coderdtest.CreateFirstUser(t, client) + member, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID) + + args := []string{ + "external-workspaces", + "create", + "my-external-workspace", + } + inv, root := clitest.New(t, args...) + clitest.SetupConfig(t, member, root) + + err := inv.Run() + require.Error(t, err) + assert.Contains(t, err.Error(), "Missing values for the required flags: template") + }) + + t.Run("CreateWithRegularTemplate", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) + owner := coderdtest.CreateFirstUser(t, client) + member, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID) + version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, completeWithRegularAgent()) + coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) + template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID) + + args := []string{ + "external-workspaces", + "create", + "my-external-workspace", + "--template", template.Name, + } + inv, root := clitest.New(t, args...) + clitest.SetupConfig(t, member, root) + + err := inv.Run() + require.Error(t, err) + assert.Contains(t, err.Error(), "does not have an external agent") + }) + + t.Run("List", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) + owner := coderdtest.CreateFirstUser(t, client) + member, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID) + version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, completeWithExternalAgent()) + coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) + template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID) + + // Create an external workspace + ws := coderdtest.CreateWorkspace(t, member, template.ID) + coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, ws.LatestBuild.ID) + + args := []string{ + "external-workspaces", + "list", + } + inv, root := clitest.New(t, args...) + clitest.SetupConfig(t, member, root) + pty := ptytest.New(t).Attach(inv) + + ctx, cancelFunc := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancelFunc() + done := make(chan any) + go func() { + errC := inv.WithContext(ctx).Run() + assert.NoError(t, errC) + close(done) + }() + pty.ExpectMatch(ws.Name) + pty.ExpectMatch(template.Name) + cancelFunc() + <-done + }) + + t.Run("ListJSON", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) + owner := coderdtest.CreateFirstUser(t, client) + member, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID) + version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, completeWithExternalAgent()) + coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) + template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID) + + // Create an external workspace + ws := coderdtest.CreateWorkspace(t, member, template.ID) + coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, ws.LatestBuild.ID) + + args := []string{ + "external-workspaces", + "list", + "--output=json", + } + inv, root := clitest.New(t, args...) + clitest.SetupConfig(t, member, root) + + ctx, cancelFunc := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancelFunc() + + out := bytes.NewBuffer(nil) + inv.Stdout = out + err := inv.WithContext(ctx).Run() + require.NoError(t, err) + + var workspaces []codersdk.Workspace + require.NoError(t, json.Unmarshal(out.Bytes(), &workspaces)) + require.Len(t, workspaces, 1) + assert.Equal(t, ws.Name, workspaces[0].Name) + }) + + t.Run("ListNoWorkspaces", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) + owner := coderdtest.CreateFirstUser(t, client) + member, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID) + + args := []string{ + "external-workspaces", + "list", + } + inv, root := clitest.New(t, args...) + clitest.SetupConfig(t, member, root) + pty := ptytest.New(t).Attach(inv) + + ctx, cancelFunc := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancelFunc() + done := make(chan any) + go func() { + errC := inv.WithContext(ctx).Run() + assert.NoError(t, errC) + close(done) + }() + pty.ExpectMatch("No workspaces found!") + pty.ExpectMatch("coder external-workspaces create") + cancelFunc() + <-done + }) + + t.Run("AgentInstructions", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) + owner := coderdtest.CreateFirstUser(t, client) + member, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID) + version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, completeWithExternalAgent()) + coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) + template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID) + + // Create an external workspace + ws := coderdtest.CreateWorkspace(t, member, template.ID) + coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, ws.LatestBuild.ID) + + args := []string{ + "external-workspaces", + "agent-instructions", + ws.Name, + } + inv, root := clitest.New(t, args...) + clitest.SetupConfig(t, member, root) + pty := ptytest.New(t).Attach(inv) + + ctx, cancelFunc := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancelFunc() + done := make(chan any) + go func() { + errC := inv.WithContext(ctx).Run() + assert.NoError(t, errC) + close(done) + }() + pty.ExpectMatch("Please run the following commands to attach external agent to the workspace") + pty.ExpectMatch("export CODER_AGENT_TOKEN=") + pty.ExpectMatch("curl -fsSL") + cancelFunc() + <-done + }) + + t.Run("AgentInstructionsJSON", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) + owner := coderdtest.CreateFirstUser(t, client) + member, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID) + version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, completeWithExternalAgent()) + coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) + template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID) + + // Create an external workspace + ws := coderdtest.CreateWorkspace(t, member, template.ID) + coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, ws.LatestBuild.ID) + + args := []string{ + "external-workspaces", + "agent-instructions", + ws.Name, + "--output=json", + } + inv, root := clitest.New(t, args...) + clitest.SetupConfig(t, member, root) + + ctx, cancelFunc := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancelFunc() + + out := bytes.NewBuffer(nil) + inv.Stdout = out + err := inv.WithContext(ctx).Run() + require.NoError(t, err) + + var agentInfo map[string]interface{} + require.NoError(t, json.Unmarshal(out.Bytes(), &agentInfo)) + assert.Equal(t, "token", agentInfo["auth_type"]) + assert.NotEmpty(t, agentInfo["auth_token"]) + assert.NotEmpty(t, agentInfo["init_script"]) + }) + + t.Run("AgentInstructionsNonExistentWorkspace", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) + owner := coderdtest.CreateFirstUser(t, client) + member, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID) + + args := []string{ + "external-workspaces", + "agent-instructions", + "non-existent-workspace", + } + inv, root := clitest.New(t, args...) + clitest.SetupConfig(t, member, root) + + err := inv.Run() + require.Error(t, err) + assert.Contains(t, err.Error(), "Resource not found") + }) + + t.Run("AgentInstructionsNonExistentAgent", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) + owner := coderdtest.CreateFirstUser(t, client) + member, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID) + version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, completeWithExternalAgent()) + coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) + template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID) + + // Create an external workspace + ws := coderdtest.CreateWorkspace(t, member, template.ID) + coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, ws.LatestBuild.ID) + + args := []string{ + "external-workspaces", + "agent-instructions", + ws.Name + ".non-existent-agent", + } + inv, root := clitest.New(t, args...) + clitest.SetupConfig(t, member, root) + + err := inv.Run() + require.Error(t, err) + assert.Contains(t, err.Error(), "agent not found by name") + }) + + t.Run("CreateWithTemplateVersion", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) + owner := coderdtest.CreateFirstUser(t, client) + member, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID) + version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, completeWithExternalAgent()) + coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) + template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID) + + args := []string{ + "external-workspaces", + "create", + "my-external-workspace", + "--template", template.Name, + "--template-version", version.Name, + "-y", + } + inv, root := clitest.New(t, args...) + clitest.SetupConfig(t, member, root) + doneChan := make(chan struct{}) + pty := ptytest.New(t).Attach(inv) + go func() { + defer close(doneChan) + err := inv.Run() + assert.NoError(t, err) + }() + + // Expect the workspace creation confirmation + pty.ExpectMatch("coder_external_agent.main") + pty.ExpectMatch("external-agent (linux, amd64)") + + // Expect the external agent instructions + pty.ExpectMatch("Please run the following commands to attach external agent") + pty.ExpectMatch("export CODER_AGENT_TOKEN=") + pty.ExpectMatch("curl -fsSL") + + <-doneChan + + // Verify the workspace was created + ws, err := member.WorkspaceByOwnerAndName(context.Background(), codersdk.Me, "my-external-workspace", codersdk.WorkspaceOptions{}) + require.NoError(t, err) + assert.Equal(t, template.Name, ws.TemplateName) + }) +} diff --git a/cli/root.go b/cli/root.go index 54215a67401dd..94f0287981e81 100644 --- a/cli/root.go +++ b/cli/root.go @@ -108,7 +108,7 @@ func (r *RootCmd) CoreSubcommands() []*serpent.Command { // Workspace Commands r.autoupdate(), r.configSSH(), - r.create(), + r.create(createOptions{}), r.deleteWorkspace(), r.favorite(), r.list(), @@ -126,6 +126,7 @@ func (r *RootCmd) CoreSubcommands() []*serpent.Command { r.unfavorite(), r.update(), r.whoami(), + r.externalWorkspaces(), // Hidden r.connectCmd(), diff --git a/cli/testdata/coder_--help.golden b/cli/testdata/coder_--help.golden index 09dd4c3bce3a5..b92b090fd491d 100644 --- a/cli/testdata/coder_--help.golden +++ b/cli/testdata/coder_--help.golden @@ -14,54 +14,57 @@ USAGE: $ coder templates init SUBCOMMANDS: - autoupdate Toggle auto-update policy for a workspace - completion Install or update shell completion scripts for the - detected or chosen shell. - config-ssh Add an SSH Host entry for your workspaces "ssh - workspace.coder" - create Create a workspace - delete Delete a workspace - dotfiles Personalize your workspace by applying a canonical - dotfiles repository - external-auth Manage external authentication - favorite Add a workspace to your favorites - list List workspaces - login Authenticate with Coder deployment - logout Unauthenticate your local session - netcheck Print network debug information for DERP and STUN - notifications Manage Coder notifications - open Open a workspace - organizations Organization related commands - ping Ping a workspace - port-forward Forward ports from a workspace to the local machine. For - reverse port forwarding, use "coder ssh -R". - provisioner View and manage provisioner daemons and jobs - publickey Output your Coder public key used for Git operations - rename Rename a workspace - reset-password Directly connect to the database to reset a user's - password - restart Restart a workspace - schedule Schedule automated start and stop times for workspaces - server Start a Coder server - show Display details of a workspace's resources and agents - speedtest Run upload and download tests from your machine to a - workspace - ssh Start a shell into a workspace or run a command - start Start a workspace - stat Show resource usage for the current workspace. - state Manually manage Terraform state to fix broken workspaces - stop Stop a workspace - support Commands for troubleshooting issues with a Coder - deployment. - templates Manage templates - tokens Manage personal access tokens - unfavorite Remove a workspace from your favorites - update Will update and start a given workspace if it is out of - date. If the workspace is already running, it will be - stopped first. - users Manage users - version Show coder version - whoami Fetch authenticated user info for Coder deployment + autoupdate Toggle auto-update policy for a workspace + completion Install or update shell completion scripts for the + detected or chosen shell. + config-ssh Add an SSH Host entry for your workspaces "ssh + workspace.coder" + create Create a workspace + delete Delete a workspace + dotfiles Personalize your workspace by applying a canonical + dotfiles repository + external-auth Manage external authentication + external-workspaces Create or manage external workspaces + favorite Add a workspace to your favorites + list List workspaces + login Authenticate with Coder deployment + logout Unauthenticate your local session + netcheck Print network debug information for DERP and STUN + notifications Manage Coder notifications + open Open a workspace + organizations Organization related commands + ping Ping a workspace + port-forward Forward ports from a workspace to the local machine. + For reverse port forwarding, use "coder ssh -R". + provisioner View and manage provisioner daemons and jobs + publickey Output your Coder public key used for Git operations + rename Rename a workspace + reset-password Directly connect to the database to reset a user's + password + restart Restart a workspace + schedule Schedule automated start and stop times for + workspaces + server Start a Coder server + show Display details of a workspace's resources and agents + speedtest Run upload and download tests from your machine to a + workspace + ssh Start a shell into a workspace or run a command + start Start a workspace + stat Show resource usage for the current workspace. + state Manually manage Terraform state to fix broken + workspaces + stop Stop a workspace + support Commands for troubleshooting issues with a Coder + deployment. + templates Manage templates + tokens Manage personal access tokens + unfavorite Remove a workspace from your favorites + update Will update and start a given workspace if it is out + of date. If the workspace is already running, it will + be stopped first. + users Manage users + version Show coder version + whoami Fetch authenticated user info for Coder deployment GLOBAL OPTIONS: Global options are applied to all commands. They can be set using environment diff --git a/cli/testdata/coder_external-workspaces_--help.golden b/cli/testdata/coder_external-workspaces_--help.golden new file mode 100644 index 0000000000000..d8b1ca8363f66 --- /dev/null +++ b/cli/testdata/coder_external-workspaces_--help.golden @@ -0,0 +1,18 @@ +coder v0.0.0-devel + +USAGE: + coder external-workspaces [flags] [subcommand] + + Create or manage external workspaces + +SUBCOMMANDS: + agent-instructions Get the instructions for an external agent + create Create a new external workspace + list List external workspaces + +OPTIONS: + -O, --org string, $CODER_ORGANIZATION + Select which organization (uuid or name) to use. + +——— +Run `coder --help` for a list of global options. diff --git a/cli/testdata/coder_external-workspaces_agent-instructions_--help.golden b/cli/testdata/coder_external-workspaces_agent-instructions_--help.golden new file mode 100644 index 0000000000000..150a21313ed8c --- /dev/null +++ b/cli/testdata/coder_external-workspaces_agent-instructions_--help.golden @@ -0,0 +1,13 @@ +coder v0.0.0-devel + +USAGE: + coder external-workspaces agent-instructions [flags] [user/]workspace[.agent] + + Get the instructions for an external agent + +OPTIONS: + -o, --output text|json (default: text) + Output format. + +——— +Run `coder --help` for a list of global options. diff --git a/cli/testdata/coder_external-workspaces_create_--help.golden b/cli/testdata/coder_external-workspaces_create_--help.golden new file mode 100644 index 0000000000000..208d2cc2296d7 --- /dev/null +++ b/cli/testdata/coder_external-workspaces_create_--help.golden @@ -0,0 +1,56 @@ +coder v0.0.0-devel + +USAGE: + coder external-workspaces create [flags] [workspace] + + Create a new external workspace + + - Create a workspace for another user (if you have permission): + + $ coder create / + +OPTIONS: + -O, --org string, $CODER_ORGANIZATION + Select which organization (uuid or name) to use. + + --automatic-updates string, $CODER_WORKSPACE_AUTOMATIC_UPDATES (default: never) + Specify automatic updates setting for the workspace (accepts 'always' + or 'never'). + + --copy-parameters-from string, $CODER_WORKSPACE_COPY_PARAMETERS_FROM + Specify the source workspace name to copy parameters from. + + --parameter string-array, $CODER_RICH_PARAMETER + Rich parameter value in the format "name=value". + + --parameter-default string-array, $CODER_RICH_PARAMETER_DEFAULT + Rich parameter default values in the format "name=value". + + --preset string, $CODER_PRESET_NAME + Specify the name of a template version preset. Use 'none' to + explicitly indicate that no preset should be used. + + --rich-parameter-file string, $CODER_RICH_PARAMETER_FILE + Specify a file path with values for rich parameters defined in the + template. The file should be in YAML format, containing key-value + pairs for the parameters. + + --start-at string, $CODER_WORKSPACE_START_AT + Specify the workspace autostart schedule. Check coder schedule start + --help for the syntax. + + --stop-after duration, $CODER_WORKSPACE_STOP_AFTER + Specify a duration after which the workspace should shut down (e.g. + 8h). + + -t, --template string, $CODER_TEMPLATE_NAME + Specify a template name. + + --template-version string, $CODER_TEMPLATE_VERSION + Specify a template version name. + + -y, --yes bool + Bypass prompts. + +——— +Run `coder --help` for a list of global options. diff --git a/cli/testdata/coder_external-workspaces_list_--help.golden b/cli/testdata/coder_external-workspaces_list_--help.golden new file mode 100644 index 0000000000000..1210bea5aa186 --- /dev/null +++ b/cli/testdata/coder_external-workspaces_list_--help.golden @@ -0,0 +1,24 @@ +coder v0.0.0-devel + +USAGE: + coder external-workspaces list [flags] + + List external workspaces + + Aliases: ls + +OPTIONS: + -a, --all bool + Specifies whether all workspaces will be listed or not. + + -c, --column [favorite|workspace|organization id|organization name|template|status|healthy|last built|current version|outdated|starts at|starts next|stops after|stops next|daily cost] (default: workspace,template,status,healthy,last built,current version,outdated) + Columns to display in table output. + + -o, --output table|json (default: table) + Output format. + + --search string (default: owner:me) + Search for a workspace with a query. + +——— +Run `coder --help` for a list of global options. diff --git a/docs/manifest.json b/docs/manifest.json index 262992388af10..66f4e6dbaf476 100644 --- a/docs/manifest.json +++ b/docs/manifest.json @@ -1159,6 +1159,26 @@ "description": "Print auth for an external provider", "path": "reference/cli/external-auth_access-token.md" }, + { + "title": "external-workspaces", + "description": "Create or manage external workspaces", + "path": "reference/cli/external-workspaces.md" + }, + { + "title": "external-workspaces agent-instructions", + "description": "Get the instructions for an external agent", + "path": "reference/cli/external-workspaces_agent-instructions.md" + }, + { + "title": "external-workspaces create", + "description": "Create a new external workspace", + "path": "reference/cli/external-workspaces_create.md" + }, + { + "title": "external-workspaces list", + "description": "List external workspaces", + "path": "reference/cli/external-workspaces_list.md" + }, { "title": "favorite", "description": "Add a workspace to your favorites", diff --git a/docs/reference/cli/external-workspaces.md b/docs/reference/cli/external-workspaces.md new file mode 100644 index 0000000000000..5e1f27a7794ad --- /dev/null +++ b/docs/reference/cli/external-workspaces.md @@ -0,0 +1,29 @@ + +# external-workspaces + +Create or manage external workspaces + +## Usage + +```console +coder external-workspaces [flags] [subcommand] +``` + +## Subcommands + +| Name | Purpose | +|--------------------------------------------------------------------------------|--------------------------------------------| +| [create](./external-workspaces_create.md) | Create a new external workspace | +| [agent-instructions](./external-workspaces_agent-instructions.md) | Get the instructions for an external agent | +| [list](./external-workspaces_list.md) | List external workspaces | + +## Options + +### -O, --org + +| | | +|-------------|----------------------------------| +| Type | string | +| Environment | $CODER_ORGANIZATION | + +Select which organization (uuid or name) to use. diff --git a/docs/reference/cli/external-workspaces_agent-instructions.md b/docs/reference/cli/external-workspaces_agent-instructions.md new file mode 100644 index 0000000000000..d284a48de7173 --- /dev/null +++ b/docs/reference/cli/external-workspaces_agent-instructions.md @@ -0,0 +1,21 @@ + +# external-workspaces agent-instructions + +Get the instructions for an external agent + +## Usage + +```console +coder external-workspaces agent-instructions [flags] [user/]workspace[.agent] +``` + +## Options + +### -o, --output + +| | | +|---------|-------------------------| +| Type | text\|json | +| Default | text | + +Output format. diff --git a/docs/reference/cli/external-workspaces_create.md b/docs/reference/cli/external-workspaces_create.md new file mode 100644 index 0000000000000..b0744387a1d70 --- /dev/null +++ b/docs/reference/cli/external-workspaces_create.md @@ -0,0 +1,128 @@ + +# external-workspaces create + +Create a new external workspace + +## Usage + +```console +coder external-workspaces create [flags] [workspace] +``` + +## Description + +```console + - Create a workspace for another user (if you have permission): + + $ coder create / +``` + +## Options + +### -t, --template + +| | | +|-------------|-----------------------------------| +| Type | string | +| Environment | $CODER_TEMPLATE_NAME | + +Specify a template name. + +### --template-version + +| | | +|-------------|--------------------------------------| +| Type | string | +| Environment | $CODER_TEMPLATE_VERSION | + +Specify a template version name. + +### --preset + +| | | +|-------------|---------------------------------| +| Type | string | +| Environment | $CODER_PRESET_NAME | + +Specify the name of a template version preset. Use 'none' to explicitly indicate that no preset should be used. + +### --start-at + +| | | +|-------------|----------------------------------------| +| Type | string | +| Environment | $CODER_WORKSPACE_START_AT | + +Specify the workspace autostart schedule. Check coder schedule start --help for the syntax. + +### --stop-after + +| | | +|-------------|------------------------------------------| +| Type | duration | +| Environment | $CODER_WORKSPACE_STOP_AFTER | + +Specify a duration after which the workspace should shut down (e.g. 8h). + +### --automatic-updates + +| | | +|-------------|-------------------------------------------------| +| Type | string | +| Environment | $CODER_WORKSPACE_AUTOMATIC_UPDATES | +| Default | never | + +Specify automatic updates setting for the workspace (accepts 'always' or 'never'). + +### --copy-parameters-from + +| | | +|-------------|----------------------------------------------------| +| Type | string | +| Environment | $CODER_WORKSPACE_COPY_PARAMETERS_FROM | + +Specify the source workspace name to copy parameters from. + +### -y, --yes + +| | | +|------|-------------------| +| Type | bool | + +Bypass prompts. + +### --parameter + +| | | +|-------------|------------------------------------| +| Type | string-array | +| Environment | $CODER_RICH_PARAMETER | + +Rich parameter value in the format "name=value". + +### --rich-parameter-file + +| | | +|-------------|-----------------------------------------| +| Type | string | +| Environment | $CODER_RICH_PARAMETER_FILE | + +Specify a file path with values for rich parameters defined in the template. The file should be in YAML format, containing key-value pairs for the parameters. + +### --parameter-default + +| | | +|-------------|--------------------------------------------| +| Type | string-array | +| Environment | $CODER_RICH_PARAMETER_DEFAULT | + +Rich parameter default values in the format "name=value". + +### -O, --org + +| | | +|-------------|----------------------------------| +| Type | string | +| Environment | $CODER_ORGANIZATION | + +Select which organization (uuid or name) to use. diff --git a/docs/reference/cli/external-workspaces_list.md b/docs/reference/cli/external-workspaces_list.md new file mode 100644 index 0000000000000..061aaa29d7a0b --- /dev/null +++ b/docs/reference/cli/external-workspaces_list.md @@ -0,0 +1,51 @@ + +# external-workspaces list + +List external workspaces + +Aliases: + +* ls + +## Usage + +```console +coder external-workspaces list [flags] +``` + +## Options + +### -a, --all + +| | | +|------|-------------------| +| Type | bool | + +Specifies whether all workspaces will be listed or not. + +### --search + +| | | +|---------|-----------------------| +| Type | string | +| Default | owner:me | + +Search for a workspace with a query. + +### -c, --column + +| | | +|---------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| Type | [favorite\|workspace\|organization id\|organization name\|template\|status\|healthy\|last built\|current version\|outdated\|starts at\|starts next\|stops after\|stops next\|daily cost] | +| Default | workspace,template,status,healthy,last built,current version,outdated | + +Columns to display in table output. + +### -o, --output + +| | | +|---------|--------------------------| +| Type | table\|json | +| Default | table | + +Output format. diff --git a/docs/reference/cli/index.md b/docs/reference/cli/index.md index 1992e5d6e9ac3..8a558030aeb9a 100644 --- a/docs/reference/cli/index.md +++ b/docs/reference/cli/index.md @@ -22,51 +22,52 @@ Coder — A tool for provisioning self-hosted development environments with Terr ## Subcommands -| Name | Purpose | -|----------------------------------------------------|------------------------------------------------------------------------------------------------------------------------------| -| [completion](./completion.md) | Install or update shell completion scripts for the detected or chosen shell. | -| [dotfiles](./dotfiles.md) | Personalize your workspace by applying a canonical dotfiles repository | -| [external-auth](./external-auth.md) | Manage external authentication | -| [login](./login.md) | Authenticate with Coder deployment | -| [logout](./logout.md) | Unauthenticate your local session | -| [netcheck](./netcheck.md) | Print network debug information for DERP and STUN | -| [notifications](./notifications.md) | Manage Coder notifications | -| [organizations](./organizations.md) | Organization related commands | -| [port-forward](./port-forward.md) | Forward ports from a workspace to the local machine. For reverse port forwarding, use "coder ssh -R". | -| [publickey](./publickey.md) | Output your Coder public key used for Git operations | -| [reset-password](./reset-password.md) | Directly connect to the database to reset a user's password | -| [state](./state.md) | Manually manage Terraform state to fix broken workspaces | -| [templates](./templates.md) | Manage templates | -| [tokens](./tokens.md) | Manage personal access tokens | -| [users](./users.md) | Manage users | -| [version](./version.md) | Show coder version | -| [autoupdate](./autoupdate.md) | Toggle auto-update policy for a workspace | -| [config-ssh](./config-ssh.md) | Add an SSH Host entry for your workspaces "ssh workspace.coder" | -| [create](./create.md) | Create a workspace | -| [delete](./delete.md) | Delete a workspace | -| [favorite](./favorite.md) | Add a workspace to your favorites | -| [list](./list.md) | List workspaces | -| [open](./open.md) | Open a workspace | -| [ping](./ping.md) | Ping a workspace | -| [rename](./rename.md) | Rename a workspace | -| [restart](./restart.md) | Restart a workspace | -| [schedule](./schedule.md) | Schedule automated start and stop times for workspaces | -| [show](./show.md) | Display details of a workspace's resources and agents | -| [speedtest](./speedtest.md) | Run upload and download tests from your machine to a workspace | -| [ssh](./ssh.md) | Start a shell into a workspace or run a command | -| [start](./start.md) | Start a workspace | -| [stat](./stat.md) | Show resource usage for the current workspace. | -| [stop](./stop.md) | Stop a workspace | -| [unfavorite](./unfavorite.md) | Remove a workspace from your favorites | -| [update](./update.md) | Will update and start a given workspace if it is out of date. If the workspace is already running, it will be stopped first. | -| [whoami](./whoami.md) | Fetch authenticated user info for Coder deployment | -| [support](./support.md) | Commands for troubleshooting issues with a Coder deployment. | -| [server](./server.md) | Start a Coder server | -| [features](./features.md) | List Enterprise features | -| [licenses](./licenses.md) | Add, delete, and list licenses | -| [groups](./groups.md) | Manage groups | -| [prebuilds](./prebuilds.md) | Manage Coder prebuilds | -| [provisioner](./provisioner.md) | View and manage provisioner daemons and jobs | +| Name | Purpose | +|--------------------------------------------------------------|------------------------------------------------------------------------------------------------------------------------------| +| [completion](./completion.md) | Install or update shell completion scripts for the detected or chosen shell. | +| [dotfiles](./dotfiles.md) | Personalize your workspace by applying a canonical dotfiles repository | +| [external-auth](./external-auth.md) | Manage external authentication | +| [login](./login.md) | Authenticate with Coder deployment | +| [logout](./logout.md) | Unauthenticate your local session | +| [netcheck](./netcheck.md) | Print network debug information for DERP and STUN | +| [notifications](./notifications.md) | Manage Coder notifications | +| [organizations](./organizations.md) | Organization related commands | +| [port-forward](./port-forward.md) | Forward ports from a workspace to the local machine. For reverse port forwarding, use "coder ssh -R". | +| [publickey](./publickey.md) | Output your Coder public key used for Git operations | +| [reset-password](./reset-password.md) | Directly connect to the database to reset a user's password | +| [state](./state.md) | Manually manage Terraform state to fix broken workspaces | +| [templates](./templates.md) | Manage templates | +| [tokens](./tokens.md) | Manage personal access tokens | +| [users](./users.md) | Manage users | +| [version](./version.md) | Show coder version | +| [autoupdate](./autoupdate.md) | Toggle auto-update policy for a workspace | +| [config-ssh](./config-ssh.md) | Add an SSH Host entry for your workspaces "ssh workspace.coder" | +| [create](./create.md) | Create a workspace | +| [delete](./delete.md) | Delete a workspace | +| [favorite](./favorite.md) | Add a workspace to your favorites | +| [list](./list.md) | List workspaces | +| [open](./open.md) | Open a workspace | +| [ping](./ping.md) | Ping a workspace | +| [rename](./rename.md) | Rename a workspace | +| [restart](./restart.md) | Restart a workspace | +| [schedule](./schedule.md) | Schedule automated start and stop times for workspaces | +| [show](./show.md) | Display details of a workspace's resources and agents | +| [speedtest](./speedtest.md) | Run upload and download tests from your machine to a workspace | +| [ssh](./ssh.md) | Start a shell into a workspace or run a command | +| [start](./start.md) | Start a workspace | +| [stat](./stat.md) | Show resource usage for the current workspace. | +| [stop](./stop.md) | Stop a workspace | +| [unfavorite](./unfavorite.md) | Remove a workspace from your favorites | +| [update](./update.md) | Will update and start a given workspace if it is out of date. If the workspace is already running, it will be stopped first. | +| [whoami](./whoami.md) | Fetch authenticated user info for Coder deployment | +| [external-workspaces](./external-workspaces.md) | Create or manage external workspaces | +| [support](./support.md) | Commands for troubleshooting issues with a Coder deployment. | +| [server](./server.md) | Start a Coder server | +| [features](./features.md) | List Enterprise features | +| [licenses](./licenses.md) | Add, delete, and list licenses | +| [groups](./groups.md) | Manage groups | +| [prebuilds](./prebuilds.md) | Manage Coder prebuilds | +| [provisioner](./provisioner.md) | View and manage provisioner daemons and jobs | ## Options From c614690fc0e76b750b02ea6d042bd275e76c5667 Mon Sep 17 00:00:00 2001 From: Kacper Sawicki Date: Mon, 11 Aug 2025 14:56:03 +0000 Subject: [PATCH 2/4] Move external-workspaces command to enterprise --- cli/create.go | 16 +- cli/exp_rpty.go | 2 +- cli/list.go | 14 +- cli/open.go | 4 +- cli/ping.go | 2 +- cli/portforward.go | 2 +- cli/root.go | 3 +- cli/schedule.go | 4 +- cli/speedtest.go | 2 +- cli/ssh.go | 8 +- cli/testdata/coder_--help.golden | 99 ++++++----- cli/vscodessh.go | 2 +- docs/reference/cli/index.md | 2 +- {cli => enterprise/cli}/externalworkspaces.go | 27 +-- .../cli}/externalworkspaces_test.go | 156 ++++++++++++++---- enterprise/cli/root.go | 1 + enterprise/cli/testdata/coder_--help.golden | 13 +- .../coder_external-workspaces_--help.golden | 0 ...orkspaces_agent-instructions_--help.golden | 0 ...r_external-workspaces_create_--help.golden | 0 ...der_external-workspaces_list_--help.golden | 0 21 files changed, 224 insertions(+), 133 deletions(-) rename {cli => enterprise/cli}/externalworkspaces.go (91%) rename {cli => enterprise/cli}/externalworkspaces_test.go (77%) rename {cli => enterprise/cli}/testdata/coder_external-workspaces_--help.golden (100%) rename {cli => enterprise/cli}/testdata/coder_external-workspaces_agent-instructions_--help.golden (100%) rename {cli => enterprise/cli}/testdata/coder_external-workspaces_create_--help.golden (100%) rename {cli => enterprise/cli}/testdata/coder_external-workspaces_list_--help.golden (100%) diff --git a/cli/create.go b/cli/create.go index db8253ddb5d03..59ab0ba0fa6d7 100644 --- a/cli/create.go +++ b/cli/create.go @@ -29,12 +29,12 @@ const PresetNone = "none" var ErrNoPresetFound = xerrors.New("no preset found") -type createOptions struct { - beforeCreate func(ctx context.Context, client *codersdk.Client, template codersdk.Template, templateVersionID uuid.UUID) error - afterCreate func(ctx context.Context, inv *serpent.Invocation, client *codersdk.Client, workspace codersdk.Workspace) error +type CreateOptions struct { + BeforeCreate func(ctx context.Context, client *codersdk.Client, template codersdk.Template, templateVersionID uuid.UUID) error + AfterCreate func(ctx context.Context, inv *serpent.Invocation, client *codersdk.Client, workspace codersdk.Workspace) error } -func (r *RootCmd) create(opts createOptions) *serpent.Command { +func (r *RootCmd) Create(opts CreateOptions) *serpent.Command { var ( templateName string templateVersion string @@ -310,8 +310,8 @@ func (r *RootCmd) create(opts createOptions) *serpent.Command { _, _ = fmt.Fprintf(inv.Stdout, "%s", cliui.Bold("No preset applied.")) } - if opts.beforeCreate != nil { - err = opts.beforeCreate(inv.Context(), client, template, templateVersionID) + if opts.BeforeCreate != nil { + err = opts.BeforeCreate(inv.Context(), client, template, templateVersionID) if err != nil { return xerrors.Errorf("before create: %w", err) } @@ -379,8 +379,8 @@ func (r *RootCmd) create(opts createOptions) *serpent.Command { cliui.Timestamp(time.Now()), ) - if opts.afterCreate != nil { - err = opts.afterCreate(inv.Context(), inv, client, workspace) + if opts.AfterCreate != nil { + err = opts.AfterCreate(inv.Context(), inv, client, workspace) if err != nil { return err } diff --git a/cli/exp_rpty.go b/cli/exp_rpty.go index 70154c57ea9bc..196328b64732c 100644 --- a/cli/exp_rpty.go +++ b/cli/exp_rpty.go @@ -97,7 +97,7 @@ func handleRPTY(inv *serpent.Invocation, client *codersdk.Client, args handleRPT reconnectID = uuid.New() } - ws, agt, _, err := getWorkspaceAndAgent(ctx, inv, client, true, args.NamedWorkspace) + ws, agt, _, err := GetWorkspaceAndAgent(ctx, inv, client, true, args.NamedWorkspace) if err != nil { return err } diff --git a/cli/list.go b/cli/list.go index 083d32c6e8fa1..278895dfd7218 100644 --- a/cli/list.go +++ b/cli/list.go @@ -18,7 +18,7 @@ import ( // workspaceListRow is the type provided to the OutputFormatter. This is a bit // dodgy but it's the only way to do complex display code for one format vs. the // other. -type workspaceListRow struct { +type WorkspaceListRow struct { // For JSON format: codersdk.Workspace `table:"-"` @@ -40,7 +40,7 @@ type workspaceListRow struct { DailyCost string `json:"-" table:"daily cost"` } -func workspaceListRowFromWorkspace(now time.Time, workspace codersdk.Workspace) workspaceListRow { +func WorkspaceListRowFromWorkspace(now time.Time, workspace codersdk.Workspace) WorkspaceListRow { status := codersdk.WorkspaceDisplayStatus(workspace.LatestBuild.Job.Status, workspace.LatestBuild.Transition) lastBuilt := now.UTC().Sub(workspace.LatestBuild.Job.CreatedAt).Truncate(time.Second) @@ -55,7 +55,7 @@ func workspaceListRowFromWorkspace(now time.Time, workspace codersdk.Workspace) favIco = "★" } workspaceName := favIco + " " + workspace.OwnerName + "/" + workspace.Name - return workspaceListRow{ + return WorkspaceListRow{ Favorite: workspace.Favorite, Workspace: workspace, WorkspaceName: workspaceName, @@ -80,7 +80,7 @@ func (r *RootCmd) list() *serpent.Command { filter cliui.WorkspaceFilter formatter = cliui.NewOutputFormatter( cliui.TableFormat( - []workspaceListRow{}, + []WorkspaceListRow{}, []string{ "workspace", "template", @@ -107,7 +107,7 @@ func (r *RootCmd) list() *serpent.Command { r.InitClient(client), ), Handler: func(inv *serpent.Invocation) error { - res, err := queryConvertWorkspaces(inv.Context(), client, filter.Filter(), workspaceListRowFromWorkspace) + res, err := QueryConvertWorkspaces(inv.Context(), client, filter.Filter(), WorkspaceListRowFromWorkspace) if err != nil { return err } @@ -137,9 +137,9 @@ func (r *RootCmd) list() *serpent.Command { // queryConvertWorkspaces is a helper function for converting // codersdk.Workspaces to a different type. // It's used by the list command to convert workspaces to -// workspaceListRow, and by the schedule command to +// WorkspaceListRow, and by the schedule command to // convert workspaces to scheduleListRow. -func queryConvertWorkspaces[T any](ctx context.Context, client *codersdk.Client, filter codersdk.WorkspaceFilter, convertF func(time.Time, codersdk.Workspace) T) ([]T, error) { +func QueryConvertWorkspaces[T any](ctx context.Context, client *codersdk.Client, filter codersdk.WorkspaceFilter, convertF func(time.Time, codersdk.Workspace) T) ([]T, error) { var empty []T workspaces, err := client.Workspaces(ctx, filter) if err != nil { diff --git a/cli/open.go b/cli/open.go index cc21ea863430d..83569e87e241a 100644 --- a/cli/open.go +++ b/cli/open.go @@ -72,7 +72,7 @@ func (r *RootCmd) openVSCode() *serpent.Command { // need to wait for the agent to start. workspaceQuery := inv.Args[0] autostart := true - workspace, workspaceAgent, otherWorkspaceAgents, err := getWorkspaceAndAgent(ctx, inv, client, autostart, workspaceQuery) + workspace, workspaceAgent, otherWorkspaceAgents, err := GetWorkspaceAndAgent(ctx, inv, client, autostart, workspaceQuery) if err != nil { return xerrors.Errorf("get workspace and agent: %w", err) } @@ -316,7 +316,7 @@ func (r *RootCmd) openApp() *serpent.Command { } workspaceName := inv.Args[0] - ws, agt, _, err := getWorkspaceAndAgent(ctx, inv, client, false, workspaceName) + ws, agt, _, err := GetWorkspaceAndAgent(ctx, inv, client, false, workspaceName) if err != nil { var sdkErr *codersdk.Error if errors.As(err, &sdkErr) && sdkErr.StatusCode() == http.StatusNotFound { diff --git a/cli/ping.go b/cli/ping.go index 0836aa8a135db..0b9fde5c62eb8 100644 --- a/cli/ping.go +++ b/cli/ping.go @@ -110,7 +110,7 @@ func (r *RootCmd) ping() *serpent.Command { defer notifyCancel() workspaceName := inv.Args[0] - _, workspaceAgent, _, err := getWorkspaceAndAgent( + _, workspaceAgent, _, err := GetWorkspaceAndAgent( ctx, inv, client, false, // Do not autostart for a ping. workspaceName, diff --git a/cli/portforward.go b/cli/portforward.go index 7a7723213f760..59c1f5827b06f 100644 --- a/cli/portforward.go +++ b/cli/portforward.go @@ -84,7 +84,7 @@ func (r *RootCmd) portForward() *serpent.Command { return xerrors.New("no port-forwards requested") } - workspace, workspaceAgent, _, err := getWorkspaceAndAgent(ctx, inv, client, !disableAutostart, inv.Args[0]) + workspace, workspaceAgent, _, err := GetWorkspaceAndAgent(ctx, inv, client, !disableAutostart, inv.Args[0]) if err != nil { return err } diff --git a/cli/root.go b/cli/root.go index 94f0287981e81..b3e67a46ad463 100644 --- a/cli/root.go +++ b/cli/root.go @@ -108,7 +108,7 @@ func (r *RootCmd) CoreSubcommands() []*serpent.Command { // Workspace Commands r.autoupdate(), r.configSSH(), - r.create(createOptions{}), + r.Create(CreateOptions{}), r.deleteWorkspace(), r.favorite(), r.list(), @@ -126,7 +126,6 @@ func (r *RootCmd) CoreSubcommands() []*serpent.Command { r.unfavorite(), r.update(), r.whoami(), - r.externalWorkspaces(), // Hidden r.connectCmd(), diff --git a/cli/schedule.go b/cli/schedule.go index 9ade82b9c4a36..8217ea7259dc0 100644 --- a/cli/schedule.go +++ b/cli/schedule.go @@ -117,7 +117,7 @@ func (r *RootCmd) scheduleShow() *serpent.Command { f.FilterQuery = fmt.Sprintf("owner:me name:%s", inv.Args[0]) } } - res, err := queryConvertWorkspaces(inv.Context(), client, f, scheduleListRowFromWorkspace) + res, err := QueryConvertWorkspaces(inv.Context(), client, f, scheduleListRowFromWorkspace) if err != nil { return err } @@ -286,7 +286,7 @@ func (r *RootCmd) scheduleExtend() *serpent.Command { } func displaySchedule(ws codersdk.Workspace, out io.Writer) error { - rows := []workspaceListRow{workspaceListRowFromWorkspace(time.Now(), ws)} + rows := []WorkspaceListRow{WorkspaceListRowFromWorkspace(time.Now(), ws)} rendered, err := cliui.DisplayTable(rows, "workspace", []string{ "workspace", "starts at", "starts next", "stops after", "stops next", }) diff --git a/cli/speedtest.go b/cli/speedtest.go index 08112f50cce2c..3827b45125842 100644 --- a/cli/speedtest.go +++ b/cli/speedtest.go @@ -83,7 +83,7 @@ func (r *RootCmd) speedtest() *serpent.Command { return xerrors.Errorf("--direct (-d) is incompatible with --%s", varDisableDirect) } - _, workspaceAgent, _, err := getWorkspaceAndAgent(ctx, inv, client, false, inv.Args[0]) + _, workspaceAgent, _, err := GetWorkspaceAndAgent(ctx, inv, client, false, inv.Args[0]) if err != nil { return err } diff --git a/cli/ssh.go b/cli/ssh.go index a2bca46c72f32..bc2bb24235ad2 100644 --- a/cli/ssh.go +++ b/cli/ssh.go @@ -754,7 +754,7 @@ func findWorkspaceAndAgentByHostname( hostname = strings.TrimSuffix(hostname, qualifiedSuffix) } hostname = normalizeWorkspaceInput(hostname) - ws, agent, _, err := getWorkspaceAndAgent(ctx, inv, client, !disableAutostart, hostname) + ws, agent, _, err := GetWorkspaceAndAgent(ctx, inv, client, !disableAutostart, hostname) return ws, agent, err } @@ -827,11 +827,11 @@ startWatchLoop: } } -// getWorkspaceAgent returns the workspace and agent selected using either the +// GetWorkspaceAndAgent returns the workspace and agent selected using either the // `[.]` syntax via `in`. It will also return any other agents // in the workspace as a slice for use in child->parent lookups. // If autoStart is true, the workspace will be started if it is not already running. -func getWorkspaceAndAgent(ctx context.Context, inv *serpent.Invocation, client *codersdk.Client, autostart bool, input string) (codersdk.Workspace, codersdk.WorkspaceAgent, []codersdk.WorkspaceAgent, error) { //nolint:revive +func GetWorkspaceAndAgent(ctx context.Context, inv *serpent.Invocation, client *codersdk.Client, autostart bool, input string) (codersdk.Workspace, codersdk.WorkspaceAgent, []codersdk.WorkspaceAgent, error) { //nolint:revive var ( workspace codersdk.Workspace // The input will be `owner/name.agent` @@ -880,7 +880,7 @@ func getWorkspaceAndAgent(ctx context.Context, inv *serpent.Invocation, client * switch cerr.StatusCode() { case http.StatusConflict: _, _ = fmt.Fprintln(inv.Stderr, "Unable to start the workspace due to conflict, the workspace may be starting, retrying without autostart...") - return getWorkspaceAndAgent(ctx, inv, client, false, input) + return GetWorkspaceAndAgent(ctx, inv, client, false, input) case http.StatusForbidden: _, err = startWorkspace(inv, client, workspace, workspaceParameterFlags{}, buildFlags{}, WorkspaceUpdate) diff --git a/cli/testdata/coder_--help.golden b/cli/testdata/coder_--help.golden index b92b090fd491d..09dd4c3bce3a5 100644 --- a/cli/testdata/coder_--help.golden +++ b/cli/testdata/coder_--help.golden @@ -14,57 +14,54 @@ USAGE: $ coder templates init SUBCOMMANDS: - autoupdate Toggle auto-update policy for a workspace - completion Install or update shell completion scripts for the - detected or chosen shell. - config-ssh Add an SSH Host entry for your workspaces "ssh - workspace.coder" - create Create a workspace - delete Delete a workspace - dotfiles Personalize your workspace by applying a canonical - dotfiles repository - external-auth Manage external authentication - external-workspaces Create or manage external workspaces - favorite Add a workspace to your favorites - list List workspaces - login Authenticate with Coder deployment - logout Unauthenticate your local session - netcheck Print network debug information for DERP and STUN - notifications Manage Coder notifications - open Open a workspace - organizations Organization related commands - ping Ping a workspace - port-forward Forward ports from a workspace to the local machine. - For reverse port forwarding, use "coder ssh -R". - provisioner View and manage provisioner daemons and jobs - publickey Output your Coder public key used for Git operations - rename Rename a workspace - reset-password Directly connect to the database to reset a user's - password - restart Restart a workspace - schedule Schedule automated start and stop times for - workspaces - server Start a Coder server - show Display details of a workspace's resources and agents - speedtest Run upload and download tests from your machine to a - workspace - ssh Start a shell into a workspace or run a command - start Start a workspace - stat Show resource usage for the current workspace. - state Manually manage Terraform state to fix broken - workspaces - stop Stop a workspace - support Commands for troubleshooting issues with a Coder - deployment. - templates Manage templates - tokens Manage personal access tokens - unfavorite Remove a workspace from your favorites - update Will update and start a given workspace if it is out - of date. If the workspace is already running, it will - be stopped first. - users Manage users - version Show coder version - whoami Fetch authenticated user info for Coder deployment + autoupdate Toggle auto-update policy for a workspace + completion Install or update shell completion scripts for the + detected or chosen shell. + config-ssh Add an SSH Host entry for your workspaces "ssh + workspace.coder" + create Create a workspace + delete Delete a workspace + dotfiles Personalize your workspace by applying a canonical + dotfiles repository + external-auth Manage external authentication + favorite Add a workspace to your favorites + list List workspaces + login Authenticate with Coder deployment + logout Unauthenticate your local session + netcheck Print network debug information for DERP and STUN + notifications Manage Coder notifications + open Open a workspace + organizations Organization related commands + ping Ping a workspace + port-forward Forward ports from a workspace to the local machine. For + reverse port forwarding, use "coder ssh -R". + provisioner View and manage provisioner daemons and jobs + publickey Output your Coder public key used for Git operations + rename Rename a workspace + reset-password Directly connect to the database to reset a user's + password + restart Restart a workspace + schedule Schedule automated start and stop times for workspaces + server Start a Coder server + show Display details of a workspace's resources and agents + speedtest Run upload and download tests from your machine to a + workspace + ssh Start a shell into a workspace or run a command + start Start a workspace + stat Show resource usage for the current workspace. + state Manually manage Terraform state to fix broken workspaces + stop Stop a workspace + support Commands for troubleshooting issues with a Coder + deployment. + templates Manage templates + tokens Manage personal access tokens + unfavorite Remove a workspace from your favorites + update Will update and start a given workspace if it is out of + date. If the workspace is already running, it will be + stopped first. + users Manage users + version Show coder version + whoami Fetch authenticated user info for Coder deployment GLOBAL OPTIONS: Global options are applied to all commands. They can be set using environment diff --git a/cli/vscodessh.go b/cli/vscodessh.go index e0b963b7ed80d..bd249b0a6f4ca 100644 --- a/cli/vscodessh.go +++ b/cli/vscodessh.go @@ -102,7 +102,7 @@ func (r *RootCmd) vscodeSSH() *serpent.Command { // will call this command after the workspace is started. autostart := false - workspace, workspaceAgent, _, err := getWorkspaceAndAgent(ctx, inv, client, autostart, fmt.Sprintf("%s/%s", owner, name)) + workspace, workspaceAgent, _, err := GetWorkspaceAndAgent(ctx, inv, client, autostart, fmt.Sprintf("%s/%s", owner, name)) if err != nil { return xerrors.Errorf("find workspace and agent: %w", err) } diff --git a/docs/reference/cli/index.md b/docs/reference/cli/index.md index 8a558030aeb9a..101186eeea91e 100644 --- a/docs/reference/cli/index.md +++ b/docs/reference/cli/index.md @@ -60,7 +60,6 @@ Coder — A tool for provisioning self-hosted development environments with Terr | [unfavorite](./unfavorite.md) | Remove a workspace from your favorites | | [update](./update.md) | Will update and start a given workspace if it is out of date. If the workspace is already running, it will be stopped first. | | [whoami](./whoami.md) | Fetch authenticated user info for Coder deployment | -| [external-workspaces](./external-workspaces.md) | Create or manage external workspaces | | [support](./support.md) | Commands for troubleshooting issues with a Coder deployment. | | [server](./server.md) | Start a Coder server | | [features](./features.md) | List Enterprise features | @@ -68,6 +67,7 @@ Coder — A tool for provisioning self-hosted development environments with Terr | [groups](./groups.md) | Manage groups | | [prebuilds](./prebuilds.md) | Manage Coder prebuilds | | [provisioner](./provisioner.md) | View and manage provisioner daemons and jobs | +| [external-workspaces](./external-workspaces.md) | Create or manage external workspaces | ## Options diff --git a/cli/externalworkspaces.go b/enterprise/cli/externalworkspaces.go similarity index 91% rename from cli/externalworkspaces.go rename to enterprise/cli/externalworkspaces.go index 5ea1d2d8c9410..c69b496e7af65 100644 --- a/cli/externalworkspaces.go +++ b/enterprise/cli/externalworkspaces.go @@ -8,6 +8,7 @@ import ( "github.com/google/uuid" "golang.org/x/xerrors" + agpl "github.com/coder/coder/v2/cli" "github.com/coder/coder/v2/cli/cliui" "github.com/coder/coder/v2/codersdk" "github.com/coder/pretty" @@ -23,7 +24,7 @@ type externalAgent struct { } func (r *RootCmd) externalWorkspaces() *serpent.Command { - orgContext := NewOrganizationContext() + orgContext := agpl.NewOrganizationContext() cmd := &serpent.Command{ Use: "external-workspaces [subcommand]", @@ -44,8 +45,8 @@ func (r *RootCmd) externalWorkspaces() *serpent.Command { // 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 { + opts := agpl.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) @@ -68,7 +69,7 @@ func (r *RootCmd) externalWorkspaceCreate() *serpent.Command { return nil }, - afterCreate: func(ctx context.Context, inv *serpent.Invocation, client *codersdk.Client, workspace codersdk.Workspace) error { + 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) @@ -85,7 +86,7 @@ func (r *RootCmd) externalWorkspaceCreate() *serpent.Command { }, } - cmd := r.create(opts) + cmd := r.Create(opts) cmd.Use = "create [workspace]" cmd.Short = "Create a new external workspace" cmd.Middleware = serpent.Chain( @@ -122,7 +123,7 @@ func (r *RootCmd) externalWorkspaceAgentInstructions() *serpent.Command { 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]) + workspace, workspaceAgent, _, err := agpl.GetWorkspaceAndAgent(inv.Context(), inv, client, false, inv.Args[0]) if err != nil { return xerrors.Errorf("find workspace and agent: %w", err) } @@ -159,7 +160,7 @@ func (r *RootCmd) externalWorkspaceList() *serpent.Command { filter cliui.WorkspaceFilter formatter = cliui.NewOutputFormatter( cliui.TableFormat( - []workspaceListRow{}, + []agpl.WorkspaceListRow{}, []string{ "workspace", "template", @@ -175,10 +176,12 @@ func (r *RootCmd) externalWorkspaceList() *serpent.Command { ) client := new(codersdk.Client) cmd := &serpent.Command{ - Annotations: workspaceCommand, - Use: "list", - Short: "List external workspaces", - Aliases: []string{"ls"}, + Annotations: map[string]string{ + "workspaces": "", + }, + Use: "list", + Short: "List external workspaces", + Aliases: []string{"ls"}, Middleware: serpent.Chain( serpent.RequireNArgs(0), r.InitClient(client), @@ -192,7 +195,7 @@ func (r *RootCmd) externalWorkspaceList() *serpent.Command { baseFilter.FilterQuery += " has-external-agent:true" } - res, err := queryConvertWorkspaces(inv.Context(), client, baseFilter, workspaceListRowFromWorkspace) + res, err := agpl.QueryConvertWorkspaces(inv.Context(), client, baseFilter, agpl.WorkspaceListRowFromWorkspace) if err != nil { return err } diff --git a/cli/externalworkspaces_test.go b/enterprise/cli/externalworkspaces_test.go similarity index 77% rename from cli/externalworkspaces_test.go rename to enterprise/cli/externalworkspaces_test.go index cf89ec4c2cb0c..7eac83bb44b69 100644 --- a/cli/externalworkspaces_test.go +++ b/enterprise/cli/externalworkspaces_test.go @@ -12,6 +12,8 @@ import ( "github.com/coder/coder/v2/cli/clitest" "github.com/coder/coder/v2/coderd/coderdtest" "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/enterprise/coderd/coderdenttest" + "github.com/coder/coder/v2/enterprise/coderd/license" "github.com/coder/coder/v2/provisioner/echo" "github.com/coder/coder/v2/provisionersdk/proto" "github.com/coder/coder/v2/pty/ptytest" @@ -121,8 +123,16 @@ func TestExternalWorkspaces(t *testing.T) { t.Run("Create", func(t *testing.T) { t.Parallel() - client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) - owner := coderdtest.CreateFirstUser(t, client) + client, owner := coderdenttest.New(t, &coderdenttest.Options{ + Options: &coderdtest.Options{ + IncludeProvisionerDaemon: true, + }, + LicenseOptions: &coderdenttest.LicenseOptions{ + Features: license.Features{ + codersdk.FeatureWorkspaceExternalAgent: 1, + }, + }, + }) member, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID) version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, completeWithExternalAgent()) coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) @@ -134,7 +144,7 @@ func TestExternalWorkspaces(t *testing.T) { "my-external-workspace", "--template", template.Name, } - inv, root := clitest.New(t, args...) + inv, root := newCLI(t, args...) clitest.SetupConfig(t, member, root) doneChan := make(chan struct{}) pty := ptytest.New(t).Attach(inv) @@ -165,8 +175,16 @@ func TestExternalWorkspaces(t *testing.T) { t.Run("CreateWithoutTemplate", func(t *testing.T) { t.Parallel() - client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) - owner := coderdtest.CreateFirstUser(t, client) + client, owner := coderdenttest.New(t, &coderdenttest.Options{ + Options: &coderdtest.Options{ + IncludeProvisionerDaemon: true, + }, + LicenseOptions: &coderdenttest.LicenseOptions{ + Features: license.Features{ + codersdk.FeatureWorkspaceExternalAgent: 1, + }, + }, + }) member, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID) args := []string{ @@ -174,7 +192,7 @@ func TestExternalWorkspaces(t *testing.T) { "create", "my-external-workspace", } - inv, root := clitest.New(t, args...) + inv, root := newCLI(t, args...) clitest.SetupConfig(t, member, root) err := inv.Run() @@ -184,8 +202,16 @@ func TestExternalWorkspaces(t *testing.T) { t.Run("CreateWithRegularTemplate", func(t *testing.T) { t.Parallel() - client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) - owner := coderdtest.CreateFirstUser(t, client) + client, owner := coderdenttest.New(t, &coderdenttest.Options{ + Options: &coderdtest.Options{ + IncludeProvisionerDaemon: true, + }, + LicenseOptions: &coderdenttest.LicenseOptions{ + Features: license.Features{ + codersdk.FeatureWorkspaceExternalAgent: 1, + }, + }, + }) member, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID) version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, completeWithRegularAgent()) coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) @@ -197,7 +223,7 @@ func TestExternalWorkspaces(t *testing.T) { "my-external-workspace", "--template", template.Name, } - inv, root := clitest.New(t, args...) + inv, root := newCLI(t, args...) clitest.SetupConfig(t, member, root) err := inv.Run() @@ -207,8 +233,16 @@ func TestExternalWorkspaces(t *testing.T) { t.Run("List", func(t *testing.T) { t.Parallel() - client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) - owner := coderdtest.CreateFirstUser(t, client) + client, owner := coderdenttest.New(t, &coderdenttest.Options{ + Options: &coderdtest.Options{ + IncludeProvisionerDaemon: true, + }, + LicenseOptions: &coderdenttest.LicenseOptions{ + Features: license.Features{ + codersdk.FeatureWorkspaceExternalAgent: 1, + }, + }, + }) member, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID) version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, completeWithExternalAgent()) coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) @@ -222,7 +256,7 @@ func TestExternalWorkspaces(t *testing.T) { "external-workspaces", "list", } - inv, root := clitest.New(t, args...) + inv, root := newCLI(t, args...) clitest.SetupConfig(t, member, root) pty := ptytest.New(t).Attach(inv) @@ -242,8 +276,16 @@ func TestExternalWorkspaces(t *testing.T) { t.Run("ListJSON", func(t *testing.T) { t.Parallel() - client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) - owner := coderdtest.CreateFirstUser(t, client) + client, owner := coderdenttest.New(t, &coderdenttest.Options{ + Options: &coderdtest.Options{ + IncludeProvisionerDaemon: true, + }, + LicenseOptions: &coderdenttest.LicenseOptions{ + Features: license.Features{ + codersdk.FeatureWorkspaceExternalAgent: 1, + }, + }, + }) member, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID) version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, completeWithExternalAgent()) coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) @@ -258,7 +300,7 @@ func TestExternalWorkspaces(t *testing.T) { "list", "--output=json", } - inv, root := clitest.New(t, args...) + inv, root := newCLI(t, args...) clitest.SetupConfig(t, member, root) ctx, cancelFunc := context.WithTimeout(context.Background(), testutil.WaitLong) @@ -277,15 +319,23 @@ func TestExternalWorkspaces(t *testing.T) { t.Run("ListNoWorkspaces", func(t *testing.T) { t.Parallel() - client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) - owner := coderdtest.CreateFirstUser(t, client) + client, owner := coderdenttest.New(t, &coderdenttest.Options{ + Options: &coderdtest.Options{ + IncludeProvisionerDaemon: true, + }, + LicenseOptions: &coderdenttest.LicenseOptions{ + Features: license.Features{ + codersdk.FeatureWorkspaceExternalAgent: 1, + }, + }, + }) member, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID) args := []string{ "external-workspaces", "list", } - inv, root := clitest.New(t, args...) + inv, root := newCLI(t, args...) clitest.SetupConfig(t, member, root) pty := ptytest.New(t).Attach(inv) @@ -305,8 +355,16 @@ func TestExternalWorkspaces(t *testing.T) { t.Run("AgentInstructions", func(t *testing.T) { t.Parallel() - client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) - owner := coderdtest.CreateFirstUser(t, client) + client, owner := coderdenttest.New(t, &coderdenttest.Options{ + Options: &coderdtest.Options{ + IncludeProvisionerDaemon: true, + }, + LicenseOptions: &coderdenttest.LicenseOptions{ + Features: license.Features{ + codersdk.FeatureWorkspaceExternalAgent: 1, + }, + }, + }) member, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID) version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, completeWithExternalAgent()) coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) @@ -321,7 +379,7 @@ func TestExternalWorkspaces(t *testing.T) { "agent-instructions", ws.Name, } - inv, root := clitest.New(t, args...) + inv, root := newCLI(t, args...) clitest.SetupConfig(t, member, root) pty := ptytest.New(t).Attach(inv) @@ -342,8 +400,16 @@ func TestExternalWorkspaces(t *testing.T) { t.Run("AgentInstructionsJSON", func(t *testing.T) { t.Parallel() - client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) - owner := coderdtest.CreateFirstUser(t, client) + client, owner := coderdenttest.New(t, &coderdenttest.Options{ + Options: &coderdtest.Options{ + IncludeProvisionerDaemon: true, + }, + LicenseOptions: &coderdenttest.LicenseOptions{ + Features: license.Features{ + codersdk.FeatureWorkspaceExternalAgent: 1, + }, + }, + }) member, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID) version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, completeWithExternalAgent()) coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) @@ -359,7 +425,7 @@ func TestExternalWorkspaces(t *testing.T) { ws.Name, "--output=json", } - inv, root := clitest.New(t, args...) + inv, root := newCLI(t, args...) clitest.SetupConfig(t, member, root) ctx, cancelFunc := context.WithTimeout(context.Background(), testutil.WaitLong) @@ -379,8 +445,16 @@ func TestExternalWorkspaces(t *testing.T) { t.Run("AgentInstructionsNonExistentWorkspace", func(t *testing.T) { t.Parallel() - client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) - owner := coderdtest.CreateFirstUser(t, client) + client, owner := coderdenttest.New(t, &coderdenttest.Options{ + Options: &coderdtest.Options{ + IncludeProvisionerDaemon: true, + }, + LicenseOptions: &coderdenttest.LicenseOptions{ + Features: license.Features{ + codersdk.FeatureWorkspaceExternalAgent: 1, + }, + }, + }) member, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID) args := []string{ @@ -388,7 +462,7 @@ func TestExternalWorkspaces(t *testing.T) { "agent-instructions", "non-existent-workspace", } - inv, root := clitest.New(t, args...) + inv, root := newCLI(t, args...) clitest.SetupConfig(t, member, root) err := inv.Run() @@ -398,8 +472,16 @@ func TestExternalWorkspaces(t *testing.T) { t.Run("AgentInstructionsNonExistentAgent", func(t *testing.T) { t.Parallel() - client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) - owner := coderdtest.CreateFirstUser(t, client) + client, owner := coderdenttest.New(t, &coderdenttest.Options{ + Options: &coderdtest.Options{ + IncludeProvisionerDaemon: true, + }, + LicenseOptions: &coderdenttest.LicenseOptions{ + Features: license.Features{ + codersdk.FeatureWorkspaceExternalAgent: 1, + }, + }, + }) member, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID) version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, completeWithExternalAgent()) coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) @@ -414,7 +496,7 @@ func TestExternalWorkspaces(t *testing.T) { "agent-instructions", ws.Name + ".non-existent-agent", } - inv, root := clitest.New(t, args...) + inv, root := newCLI(t, args...) clitest.SetupConfig(t, member, root) err := inv.Run() @@ -424,8 +506,16 @@ func TestExternalWorkspaces(t *testing.T) { t.Run("CreateWithTemplateVersion", func(t *testing.T) { t.Parallel() - client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) - owner := coderdtest.CreateFirstUser(t, client) + client, owner := coderdenttest.New(t, &coderdenttest.Options{ + Options: &coderdtest.Options{ + IncludeProvisionerDaemon: true, + }, + LicenseOptions: &coderdenttest.LicenseOptions{ + Features: license.Features{ + codersdk.FeatureWorkspaceExternalAgent: 1, + }, + }, + }) member, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID) version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, completeWithExternalAgent()) coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) @@ -439,7 +529,7 @@ func TestExternalWorkspaces(t *testing.T) { "--template-version", version.Name, "-y", } - inv, root := clitest.New(t, args...) + inv, root := newCLI(t, args...) clitest.SetupConfig(t, member, root) doneChan := make(chan struct{}) pty := ptytest.New(t).Attach(inv) diff --git a/enterprise/cli/root.go b/enterprise/cli/root.go index 5b101fdbbb4b8..ed54a76f90487 100644 --- a/enterprise/cli/root.go +++ b/enterprise/cli/root.go @@ -19,6 +19,7 @@ func (r *RootCmd) enterpriseOnly() []*serpent.Command { r.prebuilds(), r.provisionerDaemons(), r.provisionerd(), + r.externalWorkspaces(), } } diff --git a/enterprise/cli/testdata/coder_--help.golden b/enterprise/cli/testdata/coder_--help.golden index fc16bb29b9010..ddb44f78ae524 100644 --- a/enterprise/cli/testdata/coder_--help.golden +++ b/enterprise/cli/testdata/coder_--help.golden @@ -14,12 +14,13 @@ USAGE: $ coder templates init SUBCOMMANDS: - features List Enterprise features - groups Manage groups - licenses Add, delete, and list licenses - prebuilds Manage Coder prebuilds - provisioner View and manage provisioner daemons and jobs - server Start a Coder server + external-workspaces Create or manage external workspaces + features List Enterprise features + groups Manage groups + licenses Add, delete, and list licenses + prebuilds Manage Coder prebuilds + provisioner View and manage provisioner daemons and jobs + server Start a Coder server GLOBAL OPTIONS: Global options are applied to all commands. They can be set using environment diff --git a/cli/testdata/coder_external-workspaces_--help.golden b/enterprise/cli/testdata/coder_external-workspaces_--help.golden similarity index 100% rename from cli/testdata/coder_external-workspaces_--help.golden rename to enterprise/cli/testdata/coder_external-workspaces_--help.golden diff --git a/cli/testdata/coder_external-workspaces_agent-instructions_--help.golden b/enterprise/cli/testdata/coder_external-workspaces_agent-instructions_--help.golden similarity index 100% rename from cli/testdata/coder_external-workspaces_agent-instructions_--help.golden rename to enterprise/cli/testdata/coder_external-workspaces_agent-instructions_--help.golden diff --git a/cli/testdata/coder_external-workspaces_create_--help.golden b/enterprise/cli/testdata/coder_external-workspaces_create_--help.golden similarity index 100% rename from cli/testdata/coder_external-workspaces_create_--help.golden rename to enterprise/cli/testdata/coder_external-workspaces_create_--help.golden diff --git a/cli/testdata/coder_external-workspaces_list_--help.golden b/enterprise/cli/testdata/coder_external-workspaces_list_--help.golden similarity index 100% rename from cli/testdata/coder_external-workspaces_list_--help.golden rename to enterprise/cli/testdata/coder_external-workspaces_list_--help.golden From 4ffad06f416c3ce1b0de15d899df6e311cae89dd Mon Sep 17 00:00:00 2001 From: Kacper Sawicki Date: Wed, 13 Aug 2025 09:28:43 +0000 Subject: [PATCH 3/4] Apply review suggestions --- enterprise/cli/externalworkspaces.go | 22 +++++----------------- enterprise/cli/externalworkspaces_test.go | 1 + 2 files changed, 6 insertions(+), 17 deletions(-) diff --git a/enterprise/cli/externalworkspaces.go b/enterprise/cli/externalworkspaces.go index c69b496e7af65..b2737647d7398 100644 --- a/enterprise/cli/externalworkspaces.go +++ b/enterprise/cli/externalworkspaces.go @@ -47,23 +47,11 @@ func (r *RootCmd) externalWorkspaces() *serpent.Command { func (r *RootCmd) externalWorkspaceCreate() *serpent.Command { opts := agpl.CreateOptions{ BeforeCreate: func(ctx context.Context, client *codersdk.Client, _ codersdk.Template, templateVersionID uuid.UUID) error { - resources, err := client.TemplateVersionResources(ctx, templateVersionID) + version, err := client.TemplateVersion(ctx, templateVersionID) if err != nil { - return xerrors.Errorf("get template version resources: %w", err) + return xerrors.Errorf("get template version: %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 { + if !version.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) } @@ -190,9 +178,9 @@ func (r *RootCmd) externalWorkspaceList() *serpent.Command { baseFilter := filter.Filter() if baseFilter.FilterQuery == "" { - baseFilter.FilterQuery = "has-external-agent:true" + baseFilter.FilterQuery = "has_external_agent:true" } else { - baseFilter.FilterQuery += " has-external-agent:true" + baseFilter.FilterQuery += " has_external_agent:true" } res, err := agpl.QueryConvertWorkspaces(inv.Context(), client, baseFilter, agpl.WorkspaceListRowFromWorkspace) diff --git a/enterprise/cli/externalworkspaces_test.go b/enterprise/cli/externalworkspaces_test.go index 7eac83bb44b69..6006cd1a1a8a2 100644 --- a/enterprise/cli/externalworkspaces_test.go +++ b/enterprise/cli/externalworkspaces_test.go @@ -41,6 +41,7 @@ func completeWithExternalAgent() *echo.Responses { }, }, }, + HasExternalAgents: true, }, }, }, From d5f81418341be0cdbfa1c6a042b909db6818edf6 Mon Sep 17 00:00:00 2001 From: Kacper Sawicki Date: Wed, 13 Aug 2025 12:08:04 +0000 Subject: [PATCH 4/4] update JSON tags for WorkspaceName and AgentName in externalAgent struct --- enterprise/cli/externalworkspaces.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/enterprise/cli/externalworkspaces.go b/enterprise/cli/externalworkspaces.go index b2737647d7398..26bdeea2dffe7 100644 --- a/enterprise/cli/externalworkspaces.go +++ b/enterprise/cli/externalworkspaces.go @@ -16,8 +16,8 @@ import ( ) type externalAgent struct { - WorkspaceName string `json:"-"` - AgentName string `json:"-"` + WorkspaceName string `json:"workspace_name"` + AgentName string `json:"agent_name"` AuthType string `json:"auth_type"` AuthToken string `json:"auth_token"` InitScript string `json:"init_script"`