From 3ea541eef34072d1eacd7e2455167f193249a1b9 Mon Sep 17 00:00:00 2001 From: Kacper Sawicki Date: Wed, 23 Jul 2025 11:10:43 +0000 Subject: [PATCH 1/3] Add attach command and API endpoints for init-script and external agent credential --- cli/attach.go | 315 ++++++++++++++++++++++++ cli/create.go | 26 +- cli/exp_scaletest.go | 2 +- cli/root.go | 1 + cli/start.go | 2 +- cli/testdata/coder_--help.golden | 1 + cli/testdata/coder_attach_--help.golden | 38 +++ coderd/apidoc/docs.go | 81 ++++++ coderd/apidoc/swagger.json | 73 ++++++ coderd/coderd.go | 6 + coderd/init_script.go | 48 ++++ coderd/workspaceagents.go | 49 ++++ codersdk/workspaces.go | 19 ++ docs/manifest.json | 21 +- docs/reference/api/agents.md | 38 +++ docs/reference/api/initscript.md | 26 ++ docs/reference/api/schemas.md | 14 ++ docs/reference/cli/attach.md | 82 ++++++ docs/reference/cli/index.md | 1 + provisioner/terraform/resources.go | 3 +- site/src/api/typesGenerated.ts | 5 + 21 files changed, 821 insertions(+), 30 deletions(-) create mode 100644 cli/attach.go create mode 100644 cli/testdata/coder_attach_--help.golden create mode 100644 coderd/init_script.go create mode 100644 docs/reference/api/initscript.md create mode 100644 docs/reference/cli/attach.md diff --git a/cli/attach.go b/cli/attach.go new file mode 100644 index 0000000000000..288cfdfe569f2 --- /dev/null +++ b/cli/attach.go @@ -0,0 +1,315 @@ +package cli + +import ( + "fmt" + "slices" + "strings" + "time" + + "github.com/google/uuid" + "golang.org/x/xerrors" + + "github.com/coder/coder/v2/cli/cliui" + "github.com/coder/coder/v2/cli/cliutil" + "github.com/coder/coder/v2/coderd/util/slice" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/pretty" + "github.com/coder/serpent" +) + +func (r *RootCmd) attach() *serpent.Command { + var ( + templateName string + templateVersion string + workspaceName string + + parameterFlags workspaceParameterFlags + // Organization context is only required if more than 1 template + // shares the same name across multiple organizations. + orgContext = NewOrganizationContext() + ) + client := new(codersdk.Client) + cmd := &serpent.Command{ + Annotations: workspaceCommand, + Use: "attach [workspace]", + Short: "Create a workspace and attach an external agent to it", + Long: FormatExamples( + Example{ + Description: "Attach an external agent to a workspace", + Command: "coder attach my-workspace --template externally-managed-workspace --output text", + }, + ), + Middleware: serpent.Chain(r.InitClient(client)), + Handler: func(inv *serpent.Invocation) error { + var err error + workspaceOwner := codersdk.Me + if len(inv.Args) >= 1 { + workspaceOwner, workspaceName, err = splitNamedWorkspace(inv.Args[0]) + if err != nil { + return err + } + } + + if workspaceName == "" { + workspaceName, err = cliui.Prompt(inv, cliui.PromptOptions{ + Text: "Specify a name for your workspace:", + Validate: func(workspaceName string) error { + err = codersdk.NameValid(workspaceName) + if err != nil { + return xerrors.Errorf("workspace name %q is invalid: %w", workspaceName, err) + } + _, err = client.WorkspaceByOwnerAndName(inv.Context(), workspaceOwner, workspaceName, codersdk.WorkspaceOptions{}) + if err == nil { + return xerrors.Errorf("a workspace already exists named %q", workspaceName) + } + return nil + }, + }) + if err != nil { + return err + } + } + err = codersdk.NameValid(workspaceName) + if err != nil { + return xerrors.Errorf("workspace name %q is invalid: %w", workspaceName, err) + } + + if workspace, err := client.WorkspaceByOwnerAndName(inv.Context(), workspaceOwner, workspaceName, codersdk.WorkspaceOptions{}); err == nil { + return externalAgentDetails(inv, client, workspace, workspace.LatestBuild.Resources) + } + + // If workspace doesn't exist, create it + var template codersdk.Template + var templateVersionID uuid.UUID + switch { + case templateName == "": + _, _ = fmt.Fprintln(inv.Stdout, pretty.Sprint(cliui.DefaultStyles.Wrap, "Select a template below to preview the provisioned infrastructure:")) + + templates, err := client.Templates(inv.Context(), codersdk.TemplateFilter{}) + if err != nil { + return err + } + + slices.SortFunc(templates, func(a, b codersdk.Template) int { + return slice.Descending(a.ActiveUserCount, b.ActiveUserCount) + }) + + templateNames := make([]string, 0, len(templates)) + templateByName := make(map[string]codersdk.Template, len(templates)) + + // If more than 1 organization exists in the list of templates, + // then include the organization name in the select options. + uniqueOrganizations := make(map[uuid.UUID]bool) + for _, template := range templates { + uniqueOrganizations[template.OrganizationID] = true + } + + for _, template := range templates { + templateName := template.Name + if len(uniqueOrganizations) > 1 { + templateName += cliui.Placeholder( + fmt.Sprintf( + " (%s)", + template.OrganizationName, + ), + ) + } + + if template.ActiveUserCount > 0 { + templateName += cliui.Placeholder( + fmt.Sprintf( + " used by %s", + formatActiveDevelopers(template.ActiveUserCount), + ), + ) + } + + templateNames = append(templateNames, templateName) + templateByName[templateName] = template + } + + // Move the cursor up a single line for nicer display! + option, err := cliui.Select(inv, cliui.SelectOptions{ + Options: templateNames, + HideSearch: true, + }) + if err != nil { + return err + } + + template = templateByName[option] + templateVersionID = template.ActiveVersionID + default: + templates, err := client.Templates(inv.Context(), codersdk.TemplateFilter{ + ExactName: templateName, + }) + if err != nil { + return xerrors.Errorf("get template by name: %w", err) + } + if len(templates) == 0 { + return xerrors.Errorf("no template found with the name %q", templateName) + } + + if len(templates) > 1 { + templateOrgs := []string{} + for _, tpl := range templates { + templateOrgs = append(templateOrgs, tpl.OrganizationName) + } + + selectedOrg, err := orgContext.Selected(inv, client) + if err != nil { + return xerrors.Errorf("multiple templates found with the name %q, use `--org=` to specify which template by that name to use. Organizations available: %s", templateName, strings.Join(templateOrgs, ", ")) + } + + index := slices.IndexFunc(templates, func(i codersdk.Template) bool { + return i.OrganizationID == selectedOrg.ID + }) + if index == -1 { + return xerrors.Errorf("no templates found with the name %q in the organization %q. Templates by that name exist in organizations: %s. Use --org= to select one.", templateName, selectedOrg.Name, strings.Join(templateOrgs, ", ")) + } + + // remake the list with the only template selected + templates = []codersdk.Template{templates[index]} + } + + template = templates[0] + templateVersionID = template.ActiveVersionID + } + + if len(templateVersion) > 0 { + version, err := client.TemplateVersionByName(inv.Context(), template.ID, templateVersion) + if err != nil { + return xerrors.Errorf("get template version by name: %w", err) + } + templateVersionID = version.ID + } + + // If the user specified an organization via a flag or env var, the template **must** + // be in that organization. Otherwise, we should throw an error. + orgValue, orgValueSource := orgContext.ValueSource(inv) + if orgValue != "" && !(orgValueSource == serpent.ValueSourceDefault || orgValueSource == serpent.ValueSourceNone) { + selectedOrg, err := orgContext.Selected(inv, client) + if err != nil { + return err + } + + if template.OrganizationID != selectedOrg.ID { + orgNameFormat := "'--org=%q'" + if orgValueSource == serpent.ValueSourceEnv { + orgNameFormat = "CODER_ORGANIZATION=%q" + } + + return xerrors.Errorf("template is in organization %q, but %s was specified. Use %s to use this template", + template.OrganizationName, + fmt.Sprintf(orgNameFormat, selectedOrg.Name), + fmt.Sprintf(orgNameFormat, template.OrganizationName), + ) + } + } + + cliBuildParameters, err := asWorkspaceBuildParameters(parameterFlags.richParameters) + if err != nil { + return xerrors.Errorf("can't parse given parameter values: %w", err) + } + + cliBuildParameterDefaults, err := asWorkspaceBuildParameters(parameterFlags.richParameterDefaults) + if err != nil { + return xerrors.Errorf("can't parse given parameter defaults: %w", err) + } + + richParameters, resources, err := prepWorkspaceBuild(inv, client, prepWorkspaceBuildArgs{ + Action: WorkspaceCreate, + TemplateVersionID: templateVersionID, + NewWorkspaceName: workspaceName, + + RichParameterFile: parameterFlags.richParameterFile, + RichParameters: cliBuildParameters, + RichParameterDefaults: cliBuildParameterDefaults, + }) + if err != nil { + return xerrors.Errorf("prepare build: %w", err) + } + + _, err = cliui.Prompt(inv, cliui.PromptOptions{ + Text: "Confirm create?", + IsConfirm: true, + }) + if err != nil { + return err + } + + workspace, err := client.CreateUserWorkspace(inv.Context(), workspaceOwner, codersdk.CreateWorkspaceRequest{ + TemplateVersionID: templateVersionID, + Name: workspaceName, + RichParameterValues: richParameters, + }) + if err != nil { + return xerrors.Errorf("create workspace: %w", err) + } + + cliutil.WarnMatchedProvisioners(inv.Stderr, workspace.LatestBuild.MatchedProvisioners, workspace.LatestBuild.Job) + + err = cliui.WorkspaceBuild(inv.Context(), inv.Stdout, client, workspace.LatestBuild.ID) + if err != nil { + return xerrors.Errorf("watch build: %w", err) + } + + _, _ = fmt.Fprintf( + inv.Stdout, + "\nThe %s workspace has been created at %s!\n\n", + cliui.Keyword(workspace.Name), + cliui.Timestamp(time.Now()), + ) + + return externalAgentDetails(inv, client, workspace, resources) + }, + } + + cmd.Options = serpent.OptionSet{ + serpent.Option{ + Flag: "template", + FlagShorthand: "t", + Env: "CODER_TEMPLATE_NAME", + Description: "Specify a template name.", + Value: serpent.StringOf(&templateName), + }, + serpent.Option{ + Flag: "template-version", + Env: "CODER_TEMPLATE_VERSION", + Description: "Specify a template version name.", + Value: serpent.StringOf(&templateVersion), + }, + cliui.SkipPromptOption(), + } + cmd.Options = append(cmd.Options, parameterFlags.cliParameters()...) + cmd.Options = append(cmd.Options, parameterFlags.cliParameterDefaults()...) + orgContext.AttachOptions(cmd) + return cmd +} + +func externalAgentDetails(inv *serpent.Invocation, client *codersdk.Client, workspace codersdk.Workspace, resources []codersdk.WorkspaceResource) error { + if len(resources) == 0 { + return xerrors.Errorf("no resources found for workspace") + } + + for _, resource := range resources { + if resource.Type == "coder_external_agent" { + agent := resource.Agents[0] + credential, err := client.WorkspaceExternalAgentCredential(inv.Context(), workspace.ID, agent.Name) + if err != nil { + return xerrors.Errorf("get external agent token: %w", err) + } + + initScriptURL := fmt.Sprintf("%s/api/v2/init-script", client.URL) + if agent.OperatingSystem != "linux" || agent.Architecture != "amd64" { + initScriptURL = fmt.Sprintf("%s/api/v2/init-script?os=%s&arch=%s", client.URL, agent.OperatingSystem, agent.Architecture) + } + + _, _ = fmt.Fprintf(inv.Stdout, "Please run the following commands to attach an agent to the workspace %s:\n", cliui.Keyword(workspace.Name)) + _, _ = fmt.Fprintf(inv.Stdout, "%s\n", pretty.Sprint(cliui.DefaultStyles.Code, fmt.Sprintf("export CODER_AGENT_TOKEN=%s", credential.AgentToken))) + _, _ = fmt.Fprintf(inv.Stdout, "%s\n", pretty.Sprint(cliui.DefaultStyles.Code, fmt.Sprintf("curl -fsSL %s | sh", initScriptURL))) + } + } + + return nil +} diff --git a/cli/create.go b/cli/create.go index fbf26349b3b95..92711f5a18f6b 100644 --- a/cli/create.go +++ b/cli/create.go @@ -263,7 +263,7 @@ func (r *RootCmd) create() *serpent.Command { } } - richParameters, err := prepWorkspaceBuild(inv, client, prepWorkspaceBuildArgs{ + richParameters, _, err := prepWorkspaceBuild(inv, client, prepWorkspaceBuildArgs{ Action: WorkspaceCreate, TemplateVersionID: templateVersionID, NewWorkspaceName: workspaceName, @@ -385,24 +385,24 @@ type prepWorkspaceBuildArgs struct { // prepWorkspaceBuild will ensure a workspace build will succeed on the latest template version. // Any missing params will be prompted to the user. It supports rich parameters. -func prepWorkspaceBuild(inv *serpent.Invocation, client *codersdk.Client, args prepWorkspaceBuildArgs) ([]codersdk.WorkspaceBuildParameter, error) { +func prepWorkspaceBuild(inv *serpent.Invocation, client *codersdk.Client, args prepWorkspaceBuildArgs) ([]codersdk.WorkspaceBuildParameter, []codersdk.WorkspaceResource, error) { ctx := inv.Context() templateVersion, err := client.TemplateVersion(ctx, args.TemplateVersionID) if err != nil { - return nil, xerrors.Errorf("get template version: %w", err) + return nil, nil, xerrors.Errorf("get template version: %w", err) } templateVersionParameters, err := client.TemplateVersionRichParameters(inv.Context(), templateVersion.ID) if err != nil { - return nil, xerrors.Errorf("get template version rich parameters: %w", err) + return nil, nil, xerrors.Errorf("get template version rich parameters: %w", err) } parameterFile := map[string]string{} if args.RichParameterFile != "" { parameterFile, err = parseParameterMapFile(args.RichParameterFile) if err != nil { - return nil, xerrors.Errorf("can't parse parameter map file: %w", err) + return nil, nil, xerrors.Errorf("can't parse parameter map file: %w", err) } } @@ -417,7 +417,7 @@ func prepWorkspaceBuild(inv *serpent.Invocation, client *codersdk.Client, args p WithRichParametersDefaults(args.RichParameterDefaults) buildParameters, err := resolver.Resolve(inv, args.Action, templateVersionParameters) if err != nil { - return nil, err + return nil, nil, err } err = cliui.ExternalAuth(ctx, inv.Stdout, cliui.ExternalAuthOptions{ @@ -426,7 +426,7 @@ func prepWorkspaceBuild(inv *serpent.Invocation, client *codersdk.Client, args p }, }) if err != nil { - return nil, xerrors.Errorf("template version git auth: %w", err) + return nil, nil, xerrors.Errorf("template version git auth: %w", err) } // Run a dry-run with the given parameters to check correctness @@ -435,12 +435,12 @@ func prepWorkspaceBuild(inv *serpent.Invocation, client *codersdk.Client, args p RichParameterValues: buildParameters, }) if err != nil { - return nil, xerrors.Errorf("begin workspace dry-run: %w", err) + return nil, nil, xerrors.Errorf("begin workspace dry-run: %w", err) } matchedProvisioners, err := client.TemplateVersionDryRunMatchedProvisioners(inv.Context(), templateVersion.ID, dryRun.ID) if err != nil { - return nil, xerrors.Errorf("get matched provisioners: %w", err) + return nil, nil, xerrors.Errorf("get matched provisioners: %w", err) } cliutil.WarnMatchedProvisioners(inv.Stdout, &matchedProvisioners, dryRun) _, _ = fmt.Fprintln(inv.Stdout, "Planning workspace...") @@ -460,12 +460,12 @@ func prepWorkspaceBuild(inv *serpent.Invocation, client *codersdk.Client, args p if err != nil { // TODO (Dean): reprompt for parameter values if we deem it to // be a validation error - return nil, xerrors.Errorf("dry-run workspace: %w", err) + return nil, nil, xerrors.Errorf("dry-run workspace: %w", err) } resources, err := client.TemplateVersionDryRunResources(inv.Context(), templateVersion.ID, dryRun.ID) if err != nil { - return nil, xerrors.Errorf("get workspace dry-run resources: %w", err) + return nil, nil, xerrors.Errorf("get workspace dry-run resources: %w", err) } err = cliui.WorkspaceResources(inv.Stdout, resources, cliui.WorkspaceResourcesOptions{ @@ -475,8 +475,8 @@ func prepWorkspaceBuild(inv *serpent.Invocation, client *codersdk.Client, args p Title: "Workspace Preview", }) if err != nil { - return nil, xerrors.Errorf("get resources: %w", err) + return nil, nil, xerrors.Errorf("get resources: %w", err) } - return buildParameters, nil + return buildParameters, resources, nil } diff --git a/cli/exp_scaletest.go b/cli/exp_scaletest.go index a844a7e8c6258..628605f09c1e5 100644 --- a/cli/exp_scaletest.go +++ b/cli/exp_scaletest.go @@ -596,7 +596,7 @@ func (r *RootCmd) scaletestCreateWorkspaces() *serpent.Command { return xerrors.Errorf("can't parse given parameter values: %w", err) } - richParameters, err := prepWorkspaceBuild(inv, client, prepWorkspaceBuildArgs{ + richParameters, _, err := prepWorkspaceBuild(inv, client, prepWorkspaceBuildArgs{ Action: WorkspaceCreate, TemplateVersionID: tpl.ActiveVersionID, NewWorkspaceName: "scaletest-N", // TODO: the scaletest runner will pass in a different name here. Does this matter? diff --git a/cli/root.go b/cli/root.go index 54215a67401dd..eb1d9441c454e 100644 --- a/cli/root.go +++ b/cli/root.go @@ -106,6 +106,7 @@ func (r *RootCmd) CoreSubcommands() []*serpent.Command { r.version(defaultVersionInfo), // Workspace Commands + r.attach(), r.autoupdate(), r.configSSH(), r.create(), diff --git a/cli/start.go b/cli/start.go index 66c96cc9c4d75..bd6578066de1d 100644 --- a/cli/start.go +++ b/cli/start.go @@ -144,7 +144,7 @@ func buildWorkspaceStartRequest(inv *serpent.Invocation, client *codersdk.Client return codersdk.CreateWorkspaceBuildRequest{}, xerrors.Errorf("unable to parse rich parameter defaults: %w", err) } - buildParameters, err := prepWorkspaceBuild(inv, client, prepWorkspaceBuildArgs{ + buildParameters, _, err := prepWorkspaceBuild(inv, client, prepWorkspaceBuildArgs{ Action: action, TemplateVersionID: version, NewWorkspaceName: workspace.Name, diff --git a/cli/testdata/coder_--help.golden b/cli/testdata/coder_--help.golden index 09dd4c3bce3a5..e5e3dfa7762ba 100644 --- a/cli/testdata/coder_--help.golden +++ b/cli/testdata/coder_--help.golden @@ -14,6 +14,7 @@ USAGE: $ coder templates init SUBCOMMANDS: + attach Create a workspace and attach an external agent to it autoupdate Toggle auto-update policy for a workspace completion Install or update shell completion scripts for the detected or chosen shell. diff --git a/cli/testdata/coder_attach_--help.golden b/cli/testdata/coder_attach_--help.golden new file mode 100644 index 0000000000000..9d2c98fa3c3e8 --- /dev/null +++ b/cli/testdata/coder_attach_--help.golden @@ -0,0 +1,38 @@ +coder v0.0.0-devel + +USAGE: + coder attach [flags] [workspace] + + Create a workspace and attach an external agent to it + + - Attach an external agent to a workspace: + + $ coder attach my-workspace --template externally-managed-workspace + --output text + +OPTIONS: + -O, --org string, $CODER_ORGANIZATION + Select which organization (uuid or name) to use. + + --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". + + --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. + + -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/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index db44c2d2fb8a3..7285bf8cb0d99 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -1280,6 +1280,37 @@ const docTemplate = `{ } } }, + "/init-script": { + "get": { + "produces": [ + "text/plain" + ], + "tags": [ + "InitScript" + ], + "summary": "Get agent init script", + "operationId": "get-agent-init-script", + "parameters": [ + { + "type": "string", + "description": "Operating system", + "name": "os", + "in": "query" + }, + { + "type": "string", + "description": "Architecture", + "name": "arch", + "in": "query" + } + ], + "responses": { + "200": { + "description": "Success" + } + } + } + }, "/insights/daus": { "get": { "security": [ @@ -10227,6 +10258,48 @@ const docTemplate = `{ } } }, + "/workspaces/{workspace}/external-agent/{agent}/credential": { + "get": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "Agents" + ], + "summary": "Get external agent credentials", + "operationId": "get-workspace-external-agent-credentials", + "parameters": [ + { + "type": "string", + "format": "uuid", + "description": "Workspace ID", + "name": "workspace", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Agent name", + "name": "agent", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/codersdk.ExternalAgentCredential" + } + } + } + } + }, "/workspaces/{workspace}/favorite": { "put": { "security": [ @@ -12835,6 +12908,14 @@ const docTemplate = `{ "ExperimentMCPServerHTTP" ] }, + "codersdk.ExternalAgentCredential": { + "type": "object", + "properties": { + "agent_token": { + "type": "string" + } + } + }, "codersdk.ExternalAuth": { "type": "object", "properties": { diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index c4164d9dc4ed1..1171e91f4b33f 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -1108,6 +1108,33 @@ } } }, + "/init-script": { + "get": { + "produces": ["text/plain"], + "tags": ["InitScript"], + "summary": "Get agent init script", + "operationId": "get-agent-init-script", + "parameters": [ + { + "type": "string", + "description": "Operating system", + "name": "os", + "in": "query" + }, + { + "type": "string", + "description": "Architecture", + "name": "arch", + "in": "query" + } + ], + "responses": { + "200": { + "description": "Success" + } + } + } + }, "/insights/daus": { "get": { "security": [ @@ -9047,6 +9074,44 @@ } } }, + "/workspaces/{workspace}/external-agent/{agent}/credential": { + "get": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "produces": ["application/json"], + "tags": ["Agents"], + "summary": "Get external agent credentials", + "operationId": "get-workspace-external-agent-credentials", + "parameters": [ + { + "type": "string", + "format": "uuid", + "description": "Workspace ID", + "name": "workspace", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Agent name", + "name": "agent", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/codersdk.ExternalAgentCredential" + } + } + } + } + }, "/workspaces/{workspace}/favorite": { "put": { "security": [ @@ -11509,6 +11574,14 @@ "ExperimentMCPServerHTTP" ] }, + "codersdk.ExternalAgentCredential": { + "type": "object", + "properties": { + "agent_token": { + "type": "string" + } + } + }, "codersdk.ExternalAuth": { "type": "object", "properties": { diff --git a/coderd/coderd.go b/coderd/coderd.go index 9115888fc566b..a7b8f6d49930d 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -1412,6 +1412,9 @@ func New(options *Options) *API { r.Post("/", api.postWorkspaceAgentPortShare) r.Delete("/", api.deleteWorkspaceAgentPortShare) }) + r.Route("/external-agent", func(r chi.Router) { + r.Get("/{agent}/credential", api.workspaceExternalAgentCredential) + }) r.Get("/timings", api.workspaceTimings) }) }) @@ -1541,6 +1544,9 @@ func New(options *Options) *API { r.Use(apiKeyMiddleware) r.Get("/", api.tailnetRPCConn) }) + r.Route("/init-script", func(r chi.Router) { + r.Get("/", api.initScript) + }) }) if options.SwaggerEndpoint { diff --git a/coderd/init_script.go b/coderd/init_script.go new file mode 100644 index 0000000000000..6ff8e3b8d69f4 --- /dev/null +++ b/coderd/init_script.go @@ -0,0 +1,48 @@ +package coderd + +import ( + "fmt" + "net/http" + "strings" + + "github.com/coder/coder/v2/provisionersdk" +) + +// @Summary Get agent init script +// @ID get-agent-init-script +// @Produce text/plain +// @Tags InitScript +// @Param os query string false "Operating system" default "linux" +// @Param arch query string false "Architecture" default "amd64" +// @Success 200 "Success" +// @Router /init-script [get] +func (api *API) initScript(rw http.ResponseWriter, r *http.Request) { + os := "linux" + arch := "amd64" + if os := r.URL.Query().Get("os"); os != "" { + os = strings.ToLower(os) + if os != "linux" && os != "darwin" && os != "windows" { + rw.WriteHeader(http.StatusBadRequest) + return + } + } + if arch := r.URL.Query().Get("arch"); arch != "" { + arch = strings.ToLower(arch) + if arch != "amd64" && arch != "arm64" && arch != "armv7" { + rw.WriteHeader(http.StatusBadRequest) + return + } + } + + script, exists := provisionersdk.AgentScriptEnv()[fmt.Sprintf("CODER_AGENT_SCRIPT_%s_%s", os, arch)] + if !exists { + rw.WriteHeader(http.StatusNotFound) + return + } + script = strings.ReplaceAll(script, "${ACCESS_URL}", api.AccessURL.String()+"/") + script = strings.ReplaceAll(script, "${AUTH_TYPE}", "token") + + rw.Header().Set("Content-Type", "text/plain; charset=utf-8") + rw.WriteHeader(http.StatusOK) + _, _ = rw.Write([]byte(script)) +} diff --git a/coderd/workspaceagents.go b/coderd/workspaceagents.go index 3ae57d8394d43..7fb74da5859eb 100644 --- a/coderd/workspaceagents.go +++ b/coderd/workspaceagents.go @@ -2181,3 +2181,52 @@ func convertWorkspaceAgentLog(logEntry database.WorkspaceAgentLog) codersdk.Work SourceID: logEntry.LogSourceID, } } + +// @Summary Get external agent credentials +// @ID get-workspace-external-agent-credentials +// @Security CoderSessionToken +// @Produce json +// @Tags Agents +// @Param workspace path string true "Workspace ID" format(uuid) +// @Param agent path string true "Agent name" +// @Success 200 {object} codersdk.ExternalAgentCredential +// @Router /workspaces/{workspace}/external-agent/{agent}/credential [get] +func (api *API) workspaceExternalAgentCredential(rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + workspace := httpmw.WorkspaceParam(r) + agentName := chi.URLParam(r, "agent") + + build, err := api.Database.GetLatestWorkspaceBuildByWorkspaceID(ctx, workspace.ID) + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Failed to get latest workspace build.", + Detail: err.Error(), + }) + return + } + + agents, err := api.Database.GetWorkspaceAgentsByWorkspaceAndBuildNumber(ctx, database.GetWorkspaceAgentsByWorkspaceAndBuildNumberParams{ + WorkspaceID: workspace.ID, + BuildNumber: build.BuildNumber, + }) + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Failed to get workspace agents.", + Detail: err.Error(), + }) + return + } + + for _, agent := range agents { + if agent.Name == agentName { + httpapi.Write(ctx, rw, http.StatusOK, codersdk.ExternalAgentCredential{ + AgentToken: agent.AuthToken.String(), + }) + return + } + } + + httpapi.Write(ctx, rw, http.StatusNotFound, codersdk.Response{ + Message: fmt.Sprintf("External agent '%s' not found in workspace.", agentName), + }) +} diff --git a/codersdk/workspaces.go b/codersdk/workspaces.go index dee2e1b838cb9..d8c1be732c72c 100644 --- a/codersdk/workspaces.go +++ b/codersdk/workspaces.go @@ -662,3 +662,22 @@ func (c *Client) WorkspaceTimings(ctx context.Context, id uuid.UUID) (WorkspaceB var timings WorkspaceBuildTimings return timings, json.NewDecoder(res.Body).Decode(&timings) } + +// ExternalAgentCredential contains the credential needed for an external agent to connect to Coder. +type ExternalAgentCredential struct { + AgentToken string `json:"agent_token"` +} + +func (c *Client) WorkspaceExternalAgentCredential(ctx context.Context, workspaceID uuid.UUID, agentName string) (ExternalAgentCredential, error) { + path := fmt.Sprintf("/api/v2/workspaces/%s/external-agent/%s/credential", workspaceID.String(), agentName) + res, err := c.Request(ctx, http.MethodGet, path, nil) + if err != nil { + return ExternalAgentCredential{}, err + } + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + return ExternalAgentCredential{}, ReadBodyAsError(res) + } + var credential ExternalAgentCredential + return credential, json.NewDecoder(res.Body).Decode(&credential) +} diff --git a/docs/manifest.json b/docs/manifest.json index c4af214212dde..d115abe1ecdba 100644 --- a/docs/manifest.json +++ b/docs/manifest.json @@ -47,18 +47,6 @@ "path": "./about/contributing/documentation.md", "icon_path": "./images/icons/document.svg" }, - { - "title": "Modules", - "description": "Learn how to contribute modules to Coder", - "path": "./about/contributing/modules.md", - "icon_path": "./images/icons/gear.svg" - }, - { - "title": "Templates", - "description": "Learn how to contribute templates to Coder", - "path": "./about/contributing/templates.md", - "icon_path": "./images/icons/picture.svg" - }, { "title": "Backend", "description": "Our guide for backend development", @@ -710,8 +698,8 @@ "path": "./admin/integrations/platformx.md" }, { - "title": "DX", - "description": "Tag Coder Users with DX", + "title": "DX Data Cloud", + "description": "Tag Coder Users with DX Data Cloud", "path": "./admin/integrations/dx-data-cloud.md" }, { @@ -1123,6 +1111,11 @@ "path": "./reference/cli/index.md", "icon_path": "./images/icons/terminal.svg", "children": [ + { + "title": "attach", + "description": "Create a workspace and attach an external agent to it", + "path": "reference/cli/attach.md" + }, { "title": "autoupdate", "description": "Toggle auto-update policy for a workspace", diff --git a/docs/reference/api/agents.md b/docs/reference/api/agents.md index 54e9b0e6ad628..4f61b8d967ad9 100644 --- a/docs/reference/api/agents.md +++ b/docs/reference/api/agents.md @@ -1238,3 +1238,41 @@ Status Code **200** | `level` | `error` | To perform this operation, you must be authenticated. [Learn more](authentication.md). + +## Get external agent credentials + +### Code samples + +```shell +# Example request using curl +curl -X GET http://coder-server:8080/api/v2/workspaces/{workspace}/external-agent/{agent}/credential \ + -H 'Accept: application/json' \ + -H 'Coder-Session-Token: API_KEY' +``` + +`GET /workspaces/{workspace}/external-agent/{agent}/credential` + +### Parameters + +| Name | In | Type | Required | Description | +|-------------|------|--------------|----------|--------------| +| `workspace` | path | string(uuid) | true | Workspace ID | +| `agent` | path | string | true | Agent name | + +### Example responses + +> 200 Response + +```json +{ + "agent_token": "string" +} +``` + +### Responses + +| Status | Meaning | Description | Schema | +|--------|---------------------------------------------------------|-------------|--------------------------------------------------------------------------------| +| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [codersdk.ExternalAgentCredential](schemas.md#codersdkexternalagentcredential) | + +To perform this operation, you must be authenticated. [Learn more](authentication.md). diff --git a/docs/reference/api/initscript.md b/docs/reference/api/initscript.md new file mode 100644 index 0000000000000..7140e37bec23f --- /dev/null +++ b/docs/reference/api/initscript.md @@ -0,0 +1,26 @@ +# InitScript + +## Get agent init script + +### Code samples + +```shell +# Example request using curl +curl -X GET http://coder-server:8080/api/v2/init-script + +``` + +`GET /init-script` + +### Parameters + +| Name | In | Type | Required | Description | +|--------|-------|--------|----------|------------------| +| `os` | query | string | false | Operating system | +| `arch` | query | string | false | Architecture | + +### Responses + +| Status | Meaning | Description | Schema | +|--------|---------------------------------------------------------|-------------|--------| +| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | Success | | diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index c8f1c37b45b53..d73bdc12353ba 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -3304,6 +3304,20 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o | `oauth2` | | `mcp-server-http` | +## codersdk.ExternalAgentCredential + +```json +{ + "agent_token": "string" +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +|---------------|--------|----------|--------------|-------------| +| `agent_token` | string | false | | | + ## codersdk.ExternalAuth ```json diff --git a/docs/reference/cli/attach.md b/docs/reference/cli/attach.md new file mode 100644 index 0000000000000..e2ca483a03f4f --- /dev/null +++ b/docs/reference/cli/attach.md @@ -0,0 +1,82 @@ + +# attach + +Create a workspace and attach an external agent to it + +## Usage + +```console +coder attach [flags] [workspace] +``` + +## Description + +```console + - Attach an external agent to a workspace: + + $ coder attach my-workspace --template externally-managed-workspace --output text +``` + +## 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. + +### -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/index.md b/docs/reference/cli/index.md index 1992e5d6e9ac3..2757119c46813 100644 --- a/docs/reference/cli/index.md +++ b/docs/reference/cli/index.md @@ -40,6 +40,7 @@ Coder — A tool for provisioning self-hosted development environments with Terr | [tokens](./tokens.md) | Manage personal access tokens | | [users](./users.md) | Manage users | | [version](./version.md) | Show coder version | +| [attach](./attach.md) | Create a workspace and attach an external agent to it | | [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 | diff --git a/provisioner/terraform/resources.go b/provisioner/terraform/resources.go index 84174c90b435d..79dbcc9372da1 100644 --- a/provisioner/terraform/resources.go +++ b/provisioner/terraform/resources.go @@ -1250,7 +1250,8 @@ func findResourcesInGraph(graph *gographviz.Graph, tfResourcesByLabel map[string continue } // Don't associate Coder resources with other Coder resources! - if strings.HasPrefix(resource.Type, "coder_") { + // Except for coder_external_agent, which is a special case. + if strings.HasPrefix(resource.Type, "coder_") && resource.Type != "coder_external_agent" { continue } graphResources = append(graphResources, &graphResource{ diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 379cd21e03d4e..b490de16041b3 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -923,6 +923,11 @@ export const Experiments: Experiment[] = [ "workspace-usage", ]; +// From codersdk/workspaces.go +export interface ExternalAgentCredential { + readonly agent_token: string; +} + // From codersdk/externalauth.go export interface ExternalAuth { readonly authenticated: boolean; From 4818df195820b600238a737e4e754b329837e5da Mon Sep 17 00:00:00 2001 From: Kacper Sawicki Date: Thu, 24 Jul 2025 14:21:57 +0000 Subject: [PATCH 2/3] add has_external_agents column to template_versions table --- coderd/database/dbauthz/dbauthz.go | 22 +++++++ coderd/database/dbmetrics/querymetrics.go | 7 ++ coderd/database/dbmock/dbmock.go | 14 ++++ coderd/database/dump.sql | 4 +- .../000351_external_agents.down.sql | 31 +++++++++ .../migrations/000351_external_agents.up.sql | 38 +++++++++++ coderd/database/modelqueries.go | 1 + coderd/database/models.go | 10 +-- coderd/database/querier.go | 1 + coderd/database/queries.sql.go | 66 ++++++++++++++----- coderd/database/queries/templates.sql | 6 ++ coderd/database/queries/templateversions.sql | 9 +++ .../provisionerdserver/provisionerdserver.go | 12 +++- coderd/searchquery/search.go | 15 +++-- docs/admin/security/audit-logs.md | 2 +- enterprise/audit/table.go | 1 + provisioner/terraform/executor.go | 1 + provisioner/terraform/resources.go | 16 +++++ provisionerd/proto/provisionerd.pb.go | 17 ++++- provisionerd/proto/provisionerd.proto | 1 + provisionerd/runner/runner.go | 7 +- provisionersdk/proto/provisioner.pb.go | 19 ++++-- provisionersdk/proto/provisioner.proto | 1 + site/e2e/provisionerGenerated.ts | 4 ++ 24 files changed, 267 insertions(+), 38 deletions(-) create mode 100644 coderd/database/migrations/000351_external_agents.down.sql create mode 100644 coderd/database/migrations/000351_external_agents.up.sql diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index 257cbc6e6b142..ba27fee5340a1 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -4695,6 +4695,28 @@ func (q *querier) UpdateTemplateVersionDescriptionByJobID(ctx context.Context, a return q.db.UpdateTemplateVersionDescriptionByJobID(ctx, arg) } +func (q *querier) UpdateTemplateVersionExternalAgentsByJobID(ctx context.Context, arg database.UpdateTemplateVersionExternalAgentsByJobIDParams) error { + // An actor is allowed to update the template version AI task flag if they are authorized to update the template. + tv, err := q.db.GetTemplateVersionByJobID(ctx, arg.JobID) + if err != nil { + return err + } + var obj rbac.Objecter + if !tv.TemplateID.Valid { + obj = rbac.ResourceTemplate.InOrg(tv.OrganizationID) + } else { + tpl, err := q.db.GetTemplateByID(ctx, tv.TemplateID.UUID) + if err != nil { + return err + } + obj = tpl + } + if err := q.authorizeContext(ctx, policy.ActionUpdate, obj); err != nil { + return err + } + return q.db.UpdateTemplateVersionExternalAgentsByJobID(ctx, arg) +} + func (q *querier) UpdateTemplateVersionExternalAuthProvidersByJobID(ctx context.Context, arg database.UpdateTemplateVersionExternalAuthProvidersByJobIDParams) error { // An actor is allowed to update the template version external auth providers if they are authorized to update the template. tv, err := q.db.GetTemplateVersionByJobID(ctx, arg.JobID) diff --git a/coderd/database/dbmetrics/querymetrics.go b/coderd/database/dbmetrics/querymetrics.go index 811d945ac7da9..6e8b93182144b 100644 --- a/coderd/database/dbmetrics/querymetrics.go +++ b/coderd/database/dbmetrics/querymetrics.go @@ -2882,6 +2882,13 @@ func (m queryMetricsStore) UpdateTemplateVersionDescriptionByJobID(ctx context.C return err } +func (m queryMetricsStore) UpdateTemplateVersionExternalAgentsByJobID(ctx context.Context, arg database.UpdateTemplateVersionExternalAgentsByJobIDParams) error { + start := time.Now() + r0 := m.s.UpdateTemplateVersionExternalAgentsByJobID(ctx, arg) + m.queryLatencies.WithLabelValues("UpdateTemplateVersionExternalAgentsByJobID").Observe(time.Since(start).Seconds()) + return r0 +} + func (m queryMetricsStore) UpdateTemplateVersionExternalAuthProvidersByJobID(ctx context.Context, arg database.UpdateTemplateVersionExternalAuthProvidersByJobIDParams) error { start := time.Now() err := m.s.UpdateTemplateVersionExternalAuthProvidersByJobID(ctx, arg) diff --git a/coderd/database/dbmock/dbmock.go b/coderd/database/dbmock/dbmock.go index b20c3d06209b5..2d975e23fb45c 100644 --- a/coderd/database/dbmock/dbmock.go +++ b/coderd/database/dbmock/dbmock.go @@ -6155,6 +6155,20 @@ func (mr *MockStoreMockRecorder) UpdateTemplateVersionDescriptionByJobID(ctx, ar return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateTemplateVersionDescriptionByJobID", reflect.TypeOf((*MockStore)(nil).UpdateTemplateVersionDescriptionByJobID), ctx, arg) } +// UpdateTemplateVersionExternalAgentsByJobID mocks base method. +func (m *MockStore) UpdateTemplateVersionExternalAgentsByJobID(ctx context.Context, arg database.UpdateTemplateVersionExternalAgentsByJobIDParams) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpdateTemplateVersionExternalAgentsByJobID", ctx, arg) + ret0, _ := ret[0].(error) + return ret0 +} + +// UpdateTemplateVersionExternalAgentsByJobID indicates an expected call of UpdateTemplateVersionExternalAgentsByJobID. +func (mr *MockStoreMockRecorder) UpdateTemplateVersionExternalAgentsByJobID(ctx, arg any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateTemplateVersionExternalAgentsByJobID", reflect.TypeOf((*MockStore)(nil).UpdateTemplateVersionExternalAgentsByJobID), ctx, arg) +} + // UpdateTemplateVersionExternalAuthProvidersByJobID mocks base method. func (m *MockStore) UpdateTemplateVersionExternalAuthProvidersByJobID(ctx context.Context, arg database.UpdateTemplateVersionExternalAuthProvidersByJobIDParams) error { m.ctrl.T.Helper() diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index eb07a5735088f..219a75d1850f3 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -1670,7 +1670,8 @@ CREATE TABLE template_versions ( message character varying(1048576) DEFAULT ''::character varying NOT NULL, archived boolean DEFAULT false NOT NULL, source_example_id text, - has_ai_task boolean + has_ai_task boolean, + has_external_agents boolean ); COMMENT ON COLUMN template_versions.external_auth_providers IS 'IDs of External auth providers for a specific template version'; @@ -1701,6 +1702,7 @@ CREATE VIEW template_version_with_user AS template_versions.archived, template_versions.source_example_id, template_versions.has_ai_task, + template_versions.has_external_agents, COALESCE(visible_users.avatar_url, ''::text) AS created_by_avatar_url, COALESCE(visible_users.username, ''::text) AS created_by_username, COALESCE(visible_users.name, ''::text) AS created_by_name diff --git a/coderd/database/migrations/000351_external_agents.down.sql b/coderd/database/migrations/000351_external_agents.down.sql new file mode 100644 index 0000000000000..60fd37107d04b --- /dev/null +++ b/coderd/database/migrations/000351_external_agents.down.sql @@ -0,0 +1,31 @@ +ALTER TABLE template_versions DROP COLUMN has_external_agents; + +-- Recreate `template_version_with_user` as defined in dump.sql +CREATE VIEW template_version_with_user AS +SELECT + template_versions.id, + template_versions.template_id, + template_versions.organization_id, + template_versions.created_at, + template_versions.updated_at, + template_versions.name, + template_versions.readme, + template_versions.job_id, + template_versions.created_by, + template_versions.external_auth_providers, + template_versions.message, + template_versions.archived, + template_versions.source_example_id, + template_versions.has_ai_task, + COALESCE(visible_users.avatar_url, '' :: text) AS created_by_avatar_url, + COALESCE(visible_users.username, '' :: text) AS created_by_username, + COALESCE(visible_users.name, '' :: text) AS created_by_name +FROM + ( + template_versions + LEFT JOIN visible_users ON ( + (template_versions.created_by = visible_users.id) + ) + ); + +COMMENT ON VIEW template_version_with_user IS 'Joins in the username + avatar url of the created by user.'; diff --git a/coderd/database/migrations/000351_external_agents.up.sql b/coderd/database/migrations/000351_external_agents.up.sql new file mode 100644 index 0000000000000..49ba11bf7724a --- /dev/null +++ b/coderd/database/migrations/000351_external_agents.up.sql @@ -0,0 +1,38 @@ +-- Determines if a coder_ai_task resource is defined in a template version. +ALTER TABLE + template_versions +ADD + COLUMN has_external_agents BOOLEAN; + +DROP VIEW template_version_with_user; + +-- We're adding the external_agents column. +CREATE VIEW template_version_with_user AS +SELECT + template_versions.id, + template_versions.template_id, + template_versions.organization_id, + template_versions.created_at, + template_versions.updated_at, + template_versions.name, + template_versions.readme, + template_versions.job_id, + template_versions.created_by, + template_versions.external_auth_providers, + template_versions.message, + template_versions.archived, + template_versions.source_example_id, + template_versions.has_ai_task, + template_versions.has_external_agents, + COALESCE(visible_users.avatar_url, '' :: text) AS created_by_avatar_url, + COALESCE(visible_users.username, '' :: text) AS created_by_username, + COALESCE(visible_users.name, '' :: text) AS created_by_name +FROM + ( + template_versions + LEFT JOIN visible_users ON ( + (template_versions.created_by = visible_users.id) + ) + ); + +COMMENT ON VIEW template_version_with_user IS 'Joins in the username + avatar url of the created by user.'; diff --git a/coderd/database/modelqueries.go b/coderd/database/modelqueries.go index 6bb7483847a2e..8bd57a8fbefc3 100644 --- a/coderd/database/modelqueries.go +++ b/coderd/database/modelqueries.go @@ -82,6 +82,7 @@ func (q *sqlQuerier) GetAuthorizedTemplates(ctx context.Context, arg GetTemplate pq.Array(arg.IDs), arg.Deprecated, arg.HasAITask, + arg.HasExternalAgents, ) if err != nil { return nil, err diff --git a/coderd/database/models.go b/coderd/database/models.go index e23efe0de0521..3afe328a23dee 100644 --- a/coderd/database/models.go +++ b/coderd/database/models.go @@ -3568,6 +3568,7 @@ type TemplateVersion struct { Archived bool `db:"archived" json:"archived"` SourceExampleID sql.NullString `db:"source_example_id" json:"source_example_id"` HasAITask sql.NullBool `db:"has_ai_task" json:"has_ai_task"` + HasExternalAgents sql.NullBool `db:"has_external_agents" json:"has_external_agents"` CreatedByAvatarURL string `db:"created_by_avatar_url" json:"created_by_avatar_url"` CreatedByUsername string `db:"created_by_username" json:"created_by_username"` CreatedByName string `db:"created_by_name" json:"created_by_name"` @@ -3650,10 +3651,11 @@ type TemplateVersionTable struct { // IDs of External auth providers for a specific template version ExternalAuthProviders json.RawMessage `db:"external_auth_providers" json:"external_auth_providers"` // Message describing the changes in this version of the template, similar to a Git commit message. Like a commit message, this should be a short, high-level description of the changes in this version of the template. This message is immutable and should not be updated after the fact. - Message string `db:"message" json:"message"` - Archived bool `db:"archived" json:"archived"` - SourceExampleID sql.NullString `db:"source_example_id" json:"source_example_id"` - HasAITask sql.NullBool `db:"has_ai_task" json:"has_ai_task"` + Message string `db:"message" json:"message"` + Archived bool `db:"archived" json:"archived"` + SourceExampleID sql.NullString `db:"source_example_id" json:"source_example_id"` + HasAITask sql.NullBool `db:"has_ai_task" json:"has_ai_task"` + HasExternalAgents sql.NullBool `db:"has_external_agents" json:"has_external_agents"` } type TemplateVersionTerraformValue struct { diff --git a/coderd/database/querier.go b/coderd/database/querier.go index baa5d8590b1d7..ddade373145fa 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -607,6 +607,7 @@ type sqlcQuerier interface { UpdateTemplateVersionAITaskByJobID(ctx context.Context, arg UpdateTemplateVersionAITaskByJobIDParams) error UpdateTemplateVersionByID(ctx context.Context, arg UpdateTemplateVersionByIDParams) error UpdateTemplateVersionDescriptionByJobID(ctx context.Context, arg UpdateTemplateVersionDescriptionByJobIDParams) error + UpdateTemplateVersionExternalAgentsByJobID(ctx context.Context, arg UpdateTemplateVersionExternalAgentsByJobIDParams) error UpdateTemplateVersionExternalAuthProvidersByJobID(ctx context.Context, arg UpdateTemplateVersionExternalAuthProvidersByJobIDParams) error UpdateTemplateWorkspacesLastUsedAt(ctx context.Context, arg UpdateTemplateWorkspacesLastUsedAtParams) error UpdateUserDeletedByID(ctx context.Context, id uuid.UUID) error diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 82ffd069b29f5..dae2a8505ac26 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -11979,19 +11979,26 @@ WHERE tv.has_ai_task = $7 :: boolean ELSE true END + -- Filter by has_external_agents in latest version + AND CASE + WHEN $8 :: boolean IS NOT NULL THEN + tv.has_external_agents = $8 :: boolean + ELSE true + END -- Authorize Filter clause will be injected below in GetAuthorizedTemplates -- @authorize_filter ORDER BY (t.name, t.id) ASC ` type GetTemplatesWithFilterParams struct { - Deleted bool `db:"deleted" json:"deleted"` - OrganizationID uuid.UUID `db:"organization_id" json:"organization_id"` - ExactName string `db:"exact_name" json:"exact_name"` - FuzzyName string `db:"fuzzy_name" json:"fuzzy_name"` - IDs []uuid.UUID `db:"ids" json:"ids"` - Deprecated sql.NullBool `db:"deprecated" json:"deprecated"` - HasAITask sql.NullBool `db:"has_ai_task" json:"has_ai_task"` + Deleted bool `db:"deleted" json:"deleted"` + OrganizationID uuid.UUID `db:"organization_id" json:"organization_id"` + ExactName string `db:"exact_name" json:"exact_name"` + FuzzyName string `db:"fuzzy_name" json:"fuzzy_name"` + IDs []uuid.UUID `db:"ids" json:"ids"` + Deprecated sql.NullBool `db:"deprecated" json:"deprecated"` + HasAITask sql.NullBool `db:"has_ai_task" json:"has_ai_task"` + HasExternalAgents sql.NullBool `db:"has_external_agents" json:"has_external_agents"` } func (q *sqlQuerier) GetTemplatesWithFilter(ctx context.Context, arg GetTemplatesWithFilterParams) ([]Template, error) { @@ -12003,6 +12010,7 @@ func (q *sqlQuerier) GetTemplatesWithFilter(ctx context.Context, arg GetTemplate pq.Array(arg.IDs), arg.Deprecated, arg.HasAITask, + arg.HasExternalAgents, ) if err != nil { return nil, err @@ -12480,7 +12488,7 @@ FROM -- Scope an archive to a single template and ignore already archived template versions ( SELECT - id, template_id, organization_id, created_at, updated_at, name, readme, job_id, created_by, external_auth_providers, message, archived, source_example_id, has_ai_task + id, template_id, organization_id, created_at, updated_at, name, readme, job_id, created_by, external_auth_providers, message, archived, source_example_id, has_ai_task, has_external_agents FROM template_versions WHERE @@ -12581,7 +12589,7 @@ func (q *sqlQuerier) ArchiveUnusedTemplateVersions(ctx context.Context, arg Arch const getPreviousTemplateVersion = `-- name: GetPreviousTemplateVersion :one SELECT - id, template_id, organization_id, created_at, updated_at, name, readme, job_id, created_by, external_auth_providers, message, archived, source_example_id, has_ai_task, created_by_avatar_url, created_by_username, created_by_name + id, template_id, organization_id, created_at, updated_at, name, readme, job_id, created_by, external_auth_providers, message, archived, source_example_id, has_ai_task, has_external_agents, created_by_avatar_url, created_by_username, created_by_name FROM template_version_with_user AS template_versions WHERE @@ -12620,6 +12628,7 @@ func (q *sqlQuerier) GetPreviousTemplateVersion(ctx context.Context, arg GetPrev &i.Archived, &i.SourceExampleID, &i.HasAITask, + &i.HasExternalAgents, &i.CreatedByAvatarURL, &i.CreatedByUsername, &i.CreatedByName, @@ -12629,7 +12638,7 @@ func (q *sqlQuerier) GetPreviousTemplateVersion(ctx context.Context, arg GetPrev const getTemplateVersionByID = `-- name: GetTemplateVersionByID :one SELECT - id, template_id, organization_id, created_at, updated_at, name, readme, job_id, created_by, external_auth_providers, message, archived, source_example_id, has_ai_task, created_by_avatar_url, created_by_username, created_by_name + id, template_id, organization_id, created_at, updated_at, name, readme, job_id, created_by, external_auth_providers, message, archived, source_example_id, has_ai_task, has_external_agents, created_by_avatar_url, created_by_username, created_by_name FROM template_version_with_user AS template_versions WHERE @@ -12654,6 +12663,7 @@ func (q *sqlQuerier) GetTemplateVersionByID(ctx context.Context, id uuid.UUID) ( &i.Archived, &i.SourceExampleID, &i.HasAITask, + &i.HasExternalAgents, &i.CreatedByAvatarURL, &i.CreatedByUsername, &i.CreatedByName, @@ -12663,7 +12673,7 @@ func (q *sqlQuerier) GetTemplateVersionByID(ctx context.Context, id uuid.UUID) ( const getTemplateVersionByJobID = `-- name: GetTemplateVersionByJobID :one SELECT - id, template_id, organization_id, created_at, updated_at, name, readme, job_id, created_by, external_auth_providers, message, archived, source_example_id, has_ai_task, created_by_avatar_url, created_by_username, created_by_name + id, template_id, organization_id, created_at, updated_at, name, readme, job_id, created_by, external_auth_providers, message, archived, source_example_id, has_ai_task, has_external_agents, created_by_avatar_url, created_by_username, created_by_name FROM template_version_with_user AS template_versions WHERE @@ -12688,6 +12698,7 @@ func (q *sqlQuerier) GetTemplateVersionByJobID(ctx context.Context, jobID uuid.U &i.Archived, &i.SourceExampleID, &i.HasAITask, + &i.HasExternalAgents, &i.CreatedByAvatarURL, &i.CreatedByUsername, &i.CreatedByName, @@ -12697,7 +12708,7 @@ func (q *sqlQuerier) GetTemplateVersionByJobID(ctx context.Context, jobID uuid.U const getTemplateVersionByTemplateIDAndName = `-- name: GetTemplateVersionByTemplateIDAndName :one SELECT - id, template_id, organization_id, created_at, updated_at, name, readme, job_id, created_by, external_auth_providers, message, archived, source_example_id, has_ai_task, created_by_avatar_url, created_by_username, created_by_name + id, template_id, organization_id, created_at, updated_at, name, readme, job_id, created_by, external_auth_providers, message, archived, source_example_id, has_ai_task, has_external_agents, created_by_avatar_url, created_by_username, created_by_name FROM template_version_with_user AS template_versions WHERE @@ -12728,6 +12739,7 @@ func (q *sqlQuerier) GetTemplateVersionByTemplateIDAndName(ctx context.Context, &i.Archived, &i.SourceExampleID, &i.HasAITask, + &i.HasExternalAgents, &i.CreatedByAvatarURL, &i.CreatedByUsername, &i.CreatedByName, @@ -12737,7 +12749,7 @@ func (q *sqlQuerier) GetTemplateVersionByTemplateIDAndName(ctx context.Context, const getTemplateVersionsByIDs = `-- name: GetTemplateVersionsByIDs :many SELECT - id, template_id, organization_id, created_at, updated_at, name, readme, job_id, created_by, external_auth_providers, message, archived, source_example_id, has_ai_task, created_by_avatar_url, created_by_username, created_by_name + id, template_id, organization_id, created_at, updated_at, name, readme, job_id, created_by, external_auth_providers, message, archived, source_example_id, has_ai_task, has_external_agents, created_by_avatar_url, created_by_username, created_by_name FROM template_version_with_user AS template_versions WHERE @@ -12768,6 +12780,7 @@ func (q *sqlQuerier) GetTemplateVersionsByIDs(ctx context.Context, ids []uuid.UU &i.Archived, &i.SourceExampleID, &i.HasAITask, + &i.HasExternalAgents, &i.CreatedByAvatarURL, &i.CreatedByUsername, &i.CreatedByName, @@ -12787,7 +12800,7 @@ func (q *sqlQuerier) GetTemplateVersionsByIDs(ctx context.Context, ids []uuid.UU const getTemplateVersionsByTemplateID = `-- name: GetTemplateVersionsByTemplateID :many SELECT - id, template_id, organization_id, created_at, updated_at, name, readme, job_id, created_by, external_auth_providers, message, archived, source_example_id, has_ai_task, created_by_avatar_url, created_by_username, created_by_name + id, template_id, organization_id, created_at, updated_at, name, readme, job_id, created_by, external_auth_providers, message, archived, source_example_id, has_ai_task, has_external_agents, created_by_avatar_url, created_by_username, created_by_name FROM template_version_with_user AS template_versions WHERE @@ -12865,6 +12878,7 @@ func (q *sqlQuerier) GetTemplateVersionsByTemplateID(ctx context.Context, arg Ge &i.Archived, &i.SourceExampleID, &i.HasAITask, + &i.HasExternalAgents, &i.CreatedByAvatarURL, &i.CreatedByUsername, &i.CreatedByName, @@ -12883,7 +12897,7 @@ func (q *sqlQuerier) GetTemplateVersionsByTemplateID(ctx context.Context, arg Ge } const getTemplateVersionsCreatedAfter = `-- name: GetTemplateVersionsCreatedAfter :many -SELECT id, template_id, organization_id, created_at, updated_at, name, readme, job_id, created_by, external_auth_providers, message, archived, source_example_id, has_ai_task, created_by_avatar_url, created_by_username, created_by_name FROM template_version_with_user AS template_versions WHERE created_at > $1 +SELECT id, template_id, organization_id, created_at, updated_at, name, readme, job_id, created_by, external_auth_providers, message, archived, source_example_id, has_ai_task, has_external_agents, created_by_avatar_url, created_by_username, created_by_name FROM template_version_with_user AS template_versions WHERE created_at > $1 ` func (q *sqlQuerier) GetTemplateVersionsCreatedAfter(ctx context.Context, createdAt time.Time) ([]TemplateVersion, error) { @@ -12910,6 +12924,7 @@ func (q *sqlQuerier) GetTemplateVersionsCreatedAfter(ctx context.Context, create &i.Archived, &i.SourceExampleID, &i.HasAITask, + &i.HasExternalAgents, &i.CreatedByAvatarURL, &i.CreatedByUsername, &i.CreatedByName, @@ -13083,6 +13098,27 @@ func (q *sqlQuerier) UpdateTemplateVersionDescriptionByJobID(ctx context.Context return err } +const updateTemplateVersionExternalAgentsByJobID = `-- name: UpdateTemplateVersionExternalAgentsByJobID :exec +UPDATE + template_versions +SET + has_external_agents = $2, + updated_at = $3 +WHERE + job_id = $1 +` + +type UpdateTemplateVersionExternalAgentsByJobIDParams struct { + JobID uuid.UUID `db:"job_id" json:"job_id"` + HasExternalAgents sql.NullBool `db:"has_external_agents" json:"has_external_agents"` + UpdatedAt time.Time `db:"updated_at" json:"updated_at"` +} + +func (q *sqlQuerier) UpdateTemplateVersionExternalAgentsByJobID(ctx context.Context, arg UpdateTemplateVersionExternalAgentsByJobIDParams) error { + _, err := q.db.ExecContext(ctx, updateTemplateVersionExternalAgentsByJobID, arg.JobID, arg.HasExternalAgents, arg.UpdatedAt) + return err +} + const updateTemplateVersionExternalAuthProvidersByJobID = `-- name: UpdateTemplateVersionExternalAuthProvidersByJobID :exec UPDATE template_versions diff --git a/coderd/database/queries/templates.sql b/coderd/database/queries/templates.sql index d10d09daaf6ea..7f1f507d472d7 100644 --- a/coderd/database/queries/templates.sql +++ b/coderd/database/queries/templates.sql @@ -59,6 +59,12 @@ WHERE tv.has_ai_task = sqlc.narg('has_ai_task') :: boolean ELSE true END + -- Filter by has_external_agents in latest version + AND CASE + WHEN sqlc.narg('has_external_agents') :: boolean IS NOT NULL THEN + tv.has_external_agents = sqlc.narg('has_external_agents') :: boolean + ELSE true + END -- Authorize Filter clause will be injected below in GetAuthorizedTemplates -- @authorize_filter ORDER BY (t.name, t.id) ASC diff --git a/coderd/database/queries/templateversions.sql b/coderd/database/queries/templateversions.sql index 4a37413d2f439..58f6614b7d72b 100644 --- a/coderd/database/queries/templateversions.sql +++ b/coderd/database/queries/templateversions.sql @@ -238,3 +238,12 @@ RETURNING template_versions.id; -- name: HasTemplateVersionsWithAITask :one -- Determines if the template versions table has any rows with has_ai_task = TRUE. SELECT EXISTS (SELECT 1 FROM template_versions WHERE has_ai_task = TRUE); + +-- name: UpdateTemplateVersionExternalAgentsByJobID :exec +UPDATE + template_versions +SET + has_external_agents = $2, + updated_at = $3 +WHERE + job_id = $1; diff --git a/coderd/provisionerdserver/provisionerdserver.go b/coderd/provisionerdserver/provisionerdserver.go index f545169c93b31..8121e48289e40 100644 --- a/coderd/provisionerdserver/provisionerdserver.go +++ b/coderd/provisionerdserver/provisionerdserver.go @@ -1666,7 +1666,17 @@ func (s *server) completeTemplateImportJob(ctx context.Context, job database.Pro if err != nil { return xerrors.Errorf("update template version external auth providers: %w", err) } - + err = db.UpdateTemplateVersionExternalAgentsByJobID(ctx, database.UpdateTemplateVersionExternalAgentsByJobIDParams{ + JobID: jobID, + HasExternalAgents: sql.NullBool{ + Bool: jobType.TemplateImport.HasExternalAgents, + Valid: true, + }, + UpdatedAt: now, + }) + if err != nil { + return xerrors.Errorf("update template version external agents: %w", err) + } // Process terraform values plan := jobType.TemplateImport.Plan moduleFiles := jobType.TemplateImport.ModuleFiles diff --git a/coderd/searchquery/search.go b/coderd/searchquery/search.go index d35f3c94b5ff7..80f995b8ff248 100644 --- a/coderd/searchquery/search.go +++ b/coderd/searchquery/search.go @@ -277,13 +277,14 @@ func Templates(ctx context.Context, db database.Store, query string) (database.G parser := httpapi.NewQueryParamParser() filter := database.GetTemplatesWithFilterParams{ - Deleted: parser.Boolean(values, false, "deleted"), - ExactName: parser.String(values, "", "exact_name"), - FuzzyName: parser.String(values, "", "name"), - IDs: parser.UUIDs(values, []uuid.UUID{}, "ids"), - Deprecated: parser.NullableBoolean(values, sql.NullBool{}, "deprecated"), - OrganizationID: parseOrganization(ctx, db, parser, values, "organization"), - HasAITask: parser.NullableBoolean(values, sql.NullBool{}, "has-ai-task"), + Deleted: parser.Boolean(values, false, "deleted"), + ExactName: parser.String(values, "", "exact_name"), + FuzzyName: parser.String(values, "", "name"), + IDs: parser.UUIDs(values, []uuid.UUID{}, "ids"), + Deprecated: parser.NullableBoolean(values, sql.NullBool{}, "deprecated"), + OrganizationID: parseOrganization(ctx, db, parser, values, "organization"), + HasAITask: parser.NullableBoolean(values, sql.NullBool{}, "has-ai-task"), + HasExternalAgents: parser.NullableBoolean(values, sql.NullBool{}, "has-external-agents"), } parser.ErrorExcessParams(values) diff --git a/docs/admin/security/audit-logs.md b/docs/admin/security/audit-logs.md index 9aca854e46b85..2a294dfe34ca1 100644 --- a/docs/admin/security/audit-logs.md +++ b/docs/admin/security/audit-logs.md @@ -33,7 +33,7 @@ We track the following resources: | PrebuildsSettings
| |
FieldTracked
idfalse
reconciliation_pausedtrue
| | RoleSyncSettings
| |
FieldTracked
fieldtrue
mappingtrue
| | Template
write, delete | |
FieldTracked
active_version_idtrue
activity_bumptrue
allow_user_autostarttrue
allow_user_autostoptrue
allow_user_cancel_workspace_jobstrue
autostart_block_days_of_weektrue
autostop_requirement_days_of_weektrue
autostop_requirement_weekstrue
created_atfalse
created_bytrue
created_by_avatar_urlfalse
created_by_namefalse
created_by_usernamefalse
default_ttltrue
deletedfalse
deprecatedtrue
descriptiontrue
display_nametrue
failure_ttltrue
group_acltrue
icontrue
idtrue
max_port_sharing_leveltrue
nametrue
organization_display_namefalse
organization_iconfalse
organization_idfalse
organization_namefalse
provisionertrue
require_active_versiontrue
time_til_dormanttrue
time_til_dormant_autodeletetrue
updated_atfalse
use_classic_parameter_flowtrue
user_acltrue
| -| TemplateVersion
create, write | |
FieldTracked
archivedtrue
created_atfalse
created_bytrue
created_by_avatar_urlfalse
created_by_namefalse
created_by_usernamefalse
external_auth_providersfalse
has_ai_taskfalse
idtrue
job_idfalse
messagefalse
nametrue
organization_idfalse
readmetrue
source_example_idfalse
template_idtrue
updated_atfalse
| +| TemplateVersion
create, write | |
FieldTracked
archivedtrue
created_atfalse
created_bytrue
created_by_avatar_urlfalse
created_by_namefalse
created_by_usernamefalse
external_auth_providersfalse
has_ai_taskfalse
has_external_agentsfalse
idtrue
job_idfalse
messagefalse
nametrue
organization_idfalse
readmetrue
source_example_idfalse
template_idtrue
updated_atfalse
| | User
create, write, delete | |
FieldTracked
avatar_urlfalse
created_atfalse
deletedtrue
emailtrue
github_com_user_idfalse
hashed_one_time_passcodefalse
hashed_passwordtrue
idtrue
is_systemtrue
last_seen_atfalse
login_typetrue
nametrue
one_time_passcode_expires_attrue
quiet_hours_scheduletrue
rbac_rolestrue
statustrue
updated_atfalse
usernametrue
| | WorkspaceBuild
start, stop | |
FieldTracked
ai_task_sidebar_app_idfalse
build_numberfalse
created_atfalse
daily_costfalse
deadlinefalse
has_ai_taskfalse
idfalse
initiator_by_avatar_urlfalse
initiator_by_namefalse
initiator_by_usernamefalse
initiator_idfalse
job_idfalse
max_deadlinefalse
provisioner_statefalse
reasonfalse
template_version_idtrue
template_version_preset_idfalse
transitionfalse
updated_atfalse
workspace_idfalse
| | WorkspaceProxy
| |
FieldTracked
created_attrue
deletedfalse
derp_enabledtrue
derp_onlytrue
display_nametrue
icontrue
idtrue
nametrue
region_idtrue
token_hashed_secrettrue
updated_atfalse
urltrue
versiontrue
wildcard_hostnametrue
| diff --git a/enterprise/audit/table.go b/enterprise/audit/table.go index 6c1f907abfa00..72f3d72f27993 100644 --- a/enterprise/audit/table.go +++ b/enterprise/audit/table.go @@ -134,6 +134,7 @@ var auditableResourcesTypes = map[any]map[string]Action{ "archived": ActionTrack, "source_example_id": ActionIgnore, // Never changes. "has_ai_task": ActionIgnore, // Never changes. + "has_external_agents": ActionIgnore, // Never changes. }, &database.User{}: { "id": ActionTrack, diff --git a/provisioner/terraform/executor.go b/provisioner/terraform/executor.go index ea63f8c59877e..8940a1708bf19 100644 --- a/provisioner/terraform/executor.go +++ b/provisioner/terraform/executor.go @@ -363,6 +363,7 @@ func (e *executor) plan(ctx, killCtx context.Context, env, vars []string, logr l ModuleFiles: moduleFiles, HasAiTasks: state.HasAITasks, AiTasks: state.AITasks, + HasExternalAgents: state.HasExternalAgents, } return msg, nil diff --git a/provisioner/terraform/resources.go b/provisioner/terraform/resources.go index 79dbcc9372da1..9f0ad6994fd24 100644 --- a/provisioner/terraform/resources.go +++ b/provisioner/terraform/resources.go @@ -165,6 +165,7 @@ type State struct { ExternalAuthProviders []*proto.ExternalAuthProviderResource AITasks []*proto.AITask HasAITasks bool + HasExternalAgents bool } var ErrInvalidTerraformAddr = xerrors.New("invalid terraform address") @@ -188,6 +189,20 @@ func hasAITaskResources(graph *gographviz.Graph) bool { return false } +func hasExternalAgentResources(graph *gographviz.Graph) bool { + for _, node := range graph.Nodes.Lookup { + if label, exists := node.Attrs["label"]; exists { + labelValue := strings.Trim(label, `"`) + // The first condition is for the case where the resource is in the root module. + // The second condition is for the case where the resource is in a child module. + if strings.HasPrefix(labelValue, "coder_external_agent.") || strings.Contains(labelValue, ".coder_external_agent.") { + return true + } + } + } + return false +} + // ConvertState consumes Terraform state and a GraphViz representation // produced by `terraform graph` to produce resources consumable by Coder. // nolint:gocognit // This function makes more sense being large for now, until refactored. @@ -1063,6 +1078,7 @@ func ConvertState(ctx context.Context, modules []*tfjson.StateModule, rawGraph s ExternalAuthProviders: externalAuthProviders, HasAITasks: hasAITasks, AITasks: aiTasks, + HasExternalAgents: hasExternalAgentResources(graph), }, nil } diff --git a/provisionerd/proto/provisionerd.pb.go b/provisionerd/proto/provisionerd.pb.go index 9960105c78962..818719f1b3995 100644 --- a/provisionerd/proto/provisionerd.pb.go +++ b/provisionerd/proto/provisionerd.pb.go @@ -1403,6 +1403,7 @@ type CompletedJob_TemplateImport struct { ModuleFiles []byte `protobuf:"bytes,10,opt,name=module_files,json=moduleFiles,proto3" json:"module_files,omitempty"` ModuleFilesHash []byte `protobuf:"bytes,11,opt,name=module_files_hash,json=moduleFilesHash,proto3" json:"module_files_hash,omitempty"` HasAiTasks bool `protobuf:"varint,12,opt,name=has_ai_tasks,json=hasAiTasks,proto3" json:"has_ai_tasks,omitempty"` + HasExternalAgents bool `protobuf:"varint,13,opt,name=has_external_agents,json=hasExternalAgents,proto3" json:"has_external_agents,omitempty"` } func (x *CompletedJob_TemplateImport) Reset() { @@ -1521,6 +1522,13 @@ func (x *CompletedJob_TemplateImport) GetHasAiTasks() bool { return false } +func (x *CompletedJob_TemplateImport) GetHasExternalAgents() bool { + if x != nil { + return x.HasExternalAgents + } + return false +} + type CompletedJob_TemplateDryRun struct { state protoimpl.MessageState sizeCache protoimpl.SizeCache @@ -1710,7 +1718,7 @@ var file_provisionerd_proto_provisionerd_proto_rawDesc = []byte{ 0x6d, 0x69, 0x6e, 0x67, 0x52, 0x07, 0x74, 0x69, 0x6d, 0x69, 0x6e, 0x67, 0x73, 0x1a, 0x10, 0x0a, 0x0e, 0x54, 0x65, 0x6d, 0x70, 0x6c, 0x61, 0x74, 0x65, 0x49, 0x6d, 0x70, 0x6f, 0x72, 0x74, 0x1a, 0x10, 0x0a, 0x0e, 0x54, 0x65, 0x6d, 0x70, 0x6c, 0x61, 0x74, 0x65, 0x44, 0x72, 0x79, 0x52, 0x75, - 0x6e, 0x42, 0x06, 0x0a, 0x04, 0x74, 0x79, 0x70, 0x65, 0x22, 0x8b, 0x0b, 0x0a, 0x0c, 0x43, 0x6f, + 0x6e, 0x42, 0x06, 0x0a, 0x04, 0x74, 0x79, 0x70, 0x65, 0x22, 0xbb, 0x0b, 0x0a, 0x0c, 0x43, 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0x64, 0x4a, 0x6f, 0x62, 0x12, 0x15, 0x0a, 0x06, 0x6a, 0x6f, 0x62, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x6a, 0x6f, 0x62, 0x49, 0x64, 0x12, 0x54, 0x0a, 0x0f, 0x77, 0x6f, 0x72, 0x6b, 0x73, 0x70, 0x61, 0x63, 0x65, 0x5f, 0x62, @@ -1749,7 +1757,7 @@ var file_provisionerd_proto_provisionerd_proto_rawDesc = []byte{ 0x63, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x73, 0x12, 0x2e, 0x0a, 0x08, 0x61, 0x69, 0x5f, 0x74, 0x61, 0x73, 0x6b, 0x73, 0x18, 0x06, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x13, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x41, 0x49, 0x54, 0x61, 0x73, 0x6b, 0x52, 0x07, - 0x61, 0x69, 0x54, 0x61, 0x73, 0x6b, 0x73, 0x1a, 0x9f, 0x05, 0x0a, 0x0e, 0x54, 0x65, 0x6d, 0x70, + 0x61, 0x69, 0x54, 0x61, 0x73, 0x6b, 0x73, 0x1a, 0xcf, 0x05, 0x0a, 0x0e, 0x54, 0x65, 0x6d, 0x70, 0x6c, 0x61, 0x74, 0x65, 0x49, 0x6d, 0x70, 0x6f, 0x72, 0x74, 0x12, 0x3e, 0x0a, 0x0f, 0x73, 0x74, 0x61, 0x72, 0x74, 0x5f, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, @@ -1791,7 +1799,10 @@ var file_provisionerd_proto_provisionerd_proto_rawDesc = []byte{ 0x18, 0x0b, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x0f, 0x6d, 0x6f, 0x64, 0x75, 0x6c, 0x65, 0x46, 0x69, 0x6c, 0x65, 0x73, 0x48, 0x61, 0x73, 0x68, 0x12, 0x20, 0x0a, 0x0c, 0x68, 0x61, 0x73, 0x5f, 0x61, 0x69, 0x5f, 0x74, 0x61, 0x73, 0x6b, 0x73, 0x18, 0x0c, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0a, 0x68, - 0x61, 0x73, 0x41, 0x69, 0x54, 0x61, 0x73, 0x6b, 0x73, 0x1a, 0x74, 0x0a, 0x0e, 0x54, 0x65, 0x6d, + 0x61, 0x73, 0x41, 0x69, 0x54, 0x61, 0x73, 0x6b, 0x73, 0x12, 0x2e, 0x0a, 0x13, 0x68, 0x61, 0x73, + 0x5f, 0x65, 0x78, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x5f, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x73, + 0x18, 0x0d, 0x20, 0x01, 0x28, 0x08, 0x52, 0x11, 0x68, 0x61, 0x73, 0x45, 0x78, 0x74, 0x65, 0x72, + 0x6e, 0x61, 0x6c, 0x41, 0x67, 0x65, 0x6e, 0x74, 0x73, 0x1a, 0x74, 0x0a, 0x0e, 0x54, 0x65, 0x6d, 0x70, 0x6c, 0x61, 0x74, 0x65, 0x44, 0x72, 0x79, 0x52, 0x75, 0x6e, 0x12, 0x33, 0x0a, 0x09, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x52, 0x65, 0x73, diff --git a/provisionerd/proto/provisionerd.proto b/provisionerd/proto/provisionerd.proto index eeeb5f02da0fb..b008da33ea87e 100644 --- a/provisionerd/proto/provisionerd.proto +++ b/provisionerd/proto/provisionerd.proto @@ -95,6 +95,7 @@ message CompletedJob { bytes module_files = 10; bytes module_files_hash = 11; bool has_ai_tasks = 12; + bool has_external_agents = 13; } message TemplateDryRun { repeated provisioner.Resource resources = 1; diff --git a/provisionerd/runner/runner.go b/provisionerd/runner/runner.go index b80cf9060b358..924f0628820ce 100644 --- a/provisionerd/runner/runner.go +++ b/provisionerd/runner/runner.go @@ -600,8 +600,9 @@ func (r *Runner) runTemplateImport(ctx context.Context) (*proto.CompletedJob, *p // ModuleFiles are not on the stopProvision. So grab from the startProvision. ModuleFiles: startProvision.ModuleFiles, // ModuleFileHash will be populated if the file is uploaded async - ModuleFilesHash: []byte{}, - HasAiTasks: startProvision.HasAITasks, + ModuleFilesHash: []byte{}, + HasAiTasks: startProvision.HasAITasks, + HasExternalAgents: startProvision.HasExternalAgents, }, }, }, nil @@ -666,6 +667,7 @@ type templateImportProvision struct { Plan json.RawMessage ModuleFiles []byte HasAITasks bool + HasExternalAgents bool } // Performs a dry-run provision when importing a template. @@ -807,6 +809,7 @@ func (r *Runner) runTemplateImportProvisionWithRichParameters( Plan: c.Plan, ModuleFiles: moduleFilesData, HasAITasks: c.HasAiTasks, + HasExternalAgents: c.HasExternalAgents, }, nil default: return nil, xerrors.Errorf("invalid message type %q received from provisioner", diff --git a/provisionersdk/proto/provisioner.pb.go b/provisionersdk/proto/provisioner.pb.go index 7412cb6155610..e48b33946f448 100644 --- a/provisionersdk/proto/provisioner.pb.go +++ b/provisionersdk/proto/provisioner.pb.go @@ -3385,8 +3385,9 @@ type PlanComplete struct { // still need to know that such resources are defined. // // See `hasAITaskResources` in provisioner/terraform/resources.go for more details. - HasAiTasks bool `protobuf:"varint,13,opt,name=has_ai_tasks,json=hasAiTasks,proto3" json:"has_ai_tasks,omitempty"` - AiTasks []*AITask `protobuf:"bytes,14,rep,name=ai_tasks,json=aiTasks,proto3" json:"ai_tasks,omitempty"` + HasAiTasks bool `protobuf:"varint,13,opt,name=has_ai_tasks,json=hasAiTasks,proto3" json:"has_ai_tasks,omitempty"` + AiTasks []*AITask `protobuf:"bytes,14,rep,name=ai_tasks,json=aiTasks,proto3" json:"ai_tasks,omitempty"` + HasExternalAgents bool `protobuf:"varint,15,opt,name=has_external_agents,json=hasExternalAgents,proto3" json:"has_external_agents,omitempty"` } func (x *PlanComplete) Reset() { @@ -3512,6 +3513,13 @@ func (x *PlanComplete) GetAiTasks() []*AITask { return nil } +func (x *PlanComplete) GetHasExternalAgents() bool { + if x != nil { + return x.HasExternalAgents + } + return false +} + // ApplyRequest asks the provisioner to apply the changes. Apply MUST be preceded by a successful plan request/response // in the same Session. The plan data is not transmitted over the wire and is cached by the provisioner in the Session. type ApplyRequest struct { @@ -4836,7 +4844,7 @@ var file_provisionersdk_proto_provisioner_proto_rawDesc = []byte{ 0x56, 0x61, 0x6c, 0x75, 0x65, 0x73, 0x12, 0x2a, 0x0a, 0x11, 0x6f, 0x6d, 0x69, 0x74, 0x5f, 0x6d, 0x6f, 0x64, 0x75, 0x6c, 0x65, 0x5f, 0x66, 0x69, 0x6c, 0x65, 0x73, 0x18, 0x06, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0f, 0x6f, 0x6d, 0x69, 0x74, 0x4d, 0x6f, 0x64, 0x75, 0x6c, 0x65, 0x46, 0x69, 0x6c, - 0x65, 0x73, 0x22, 0x91, 0x05, 0x0a, 0x0c, 0x50, 0x6c, 0x61, 0x6e, 0x43, 0x6f, 0x6d, 0x70, 0x6c, + 0x65, 0x73, 0x22, 0xc1, 0x05, 0x0a, 0x0c, 0x50, 0x6c, 0x61, 0x6e, 0x43, 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x12, 0x33, 0x0a, 0x09, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x70, @@ -4877,7 +4885,10 @@ var file_provisionersdk_proto_provisioner_proto_rawDesc = []byte{ 0x69, 0x54, 0x61, 0x73, 0x6b, 0x73, 0x12, 0x2e, 0x0a, 0x08, 0x61, 0x69, 0x5f, 0x74, 0x61, 0x73, 0x6b, 0x73, 0x18, 0x0e, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x13, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x41, 0x49, 0x54, 0x61, 0x73, 0x6b, 0x52, 0x07, 0x61, - 0x69, 0x54, 0x61, 0x73, 0x6b, 0x73, 0x22, 0x41, 0x0a, 0x0c, 0x41, 0x70, 0x70, 0x6c, 0x79, 0x52, + 0x69, 0x54, 0x61, 0x73, 0x6b, 0x73, 0x12, 0x2e, 0x0a, 0x13, 0x68, 0x61, 0x73, 0x5f, 0x65, 0x78, + 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x5f, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x73, 0x18, 0x0f, 0x20, + 0x01, 0x28, 0x08, 0x52, 0x11, 0x68, 0x61, 0x73, 0x45, 0x78, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, + 0x41, 0x67, 0x65, 0x6e, 0x74, 0x73, 0x22, 0x41, 0x0a, 0x0c, 0x41, 0x70, 0x70, 0x6c, 0x79, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x31, 0x0a, 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x52, diff --git a/provisionersdk/proto/provisioner.proto b/provisionersdk/proto/provisioner.proto index a57983c21ad9b..c46869171bb23 100644 --- a/provisionersdk/proto/provisioner.proto +++ b/provisionersdk/proto/provisioner.proto @@ -417,6 +417,7 @@ message PlanComplete { // See `hasAITaskResources` in provisioner/terraform/resources.go for more details. bool has_ai_tasks = 13; repeated provisioner.AITask ai_tasks = 14; + bool has_external_agents = 15; } // ApplyRequest asks the provisioner to apply the changes. Apply MUST be preceded by a successful plan request/response diff --git a/site/e2e/provisionerGenerated.ts b/site/e2e/provisionerGenerated.ts index 686dfb7031945..0a8fcff62b786 100644 --- a/site/e2e/provisionerGenerated.ts +++ b/site/e2e/provisionerGenerated.ts @@ -460,6 +460,7 @@ export interface PlanComplete { */ hasAiTasks: boolean; aiTasks: AITask[]; + hasExternalAgents: boolean; } /** @@ -1387,6 +1388,9 @@ export const PlanComplete = { for (const v of message.aiTasks) { AITask.encode(v!, writer.uint32(114).fork()).ldelim(); } + if (message.hasExternalAgents === true) { + writer.uint32(120).bool(message.hasExternalAgents); + } return writer; }, }; From 4bfdb83f0242df0dbe1fb51a12849fb9955d77b3 Mon Sep 17 00:00:00 2001 From: Kacper Sawicki Date: Mon, 28 Jul 2025 14:24:37 +0000 Subject: [PATCH 3/3] add external workspace creation and agent instruction commands to cli --- cli/attach.go | 315 ------------------ cli/external_workspaces.go | 260 +++++++++++++++ cli/root.go | 3 + cli/testdata/coder_--help.golden | 101 +++--- .../coder_external-workspaces_--help.golden | 17 + ...orkspaces_agent-instructions_--help.golden | 14 + ...r_external-workspaces_create_--help.golden | 52 +++ coderd/apidoc/docs.go | 6 +- coderd/apidoc/swagger.json | 6 +- coderd/coderd.go | 2 +- coderd/workspaceagents.go | 8 +- codersdk/workspaces.go | 16 +- docs/manifest.json | 15 + docs/reference/api/agents.md | 10 +- docs/reference/api/schemas.md | 2 +- docs/reference/cli/external-workspaces.md | 28 ++ .../external-workspaces_agent-instructions.md | 21 ++ .../cli/external-workspaces_create.md | 119 +++++++ docs/reference/cli/index.md | 93 +++--- site/src/api/typesGenerated.ts | 2 +- 20 files changed, 654 insertions(+), 436 deletions(-) delete mode 100644 cli/attach.go create mode 100644 cli/external_workspaces.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 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 diff --git a/cli/attach.go b/cli/attach.go deleted file mode 100644 index 288cfdfe569f2..0000000000000 --- a/cli/attach.go +++ /dev/null @@ -1,315 +0,0 @@ -package cli - -import ( - "fmt" - "slices" - "strings" - "time" - - "github.com/google/uuid" - "golang.org/x/xerrors" - - "github.com/coder/coder/v2/cli/cliui" - "github.com/coder/coder/v2/cli/cliutil" - "github.com/coder/coder/v2/coderd/util/slice" - "github.com/coder/coder/v2/codersdk" - "github.com/coder/pretty" - "github.com/coder/serpent" -) - -func (r *RootCmd) attach() *serpent.Command { - var ( - templateName string - templateVersion string - workspaceName string - - parameterFlags workspaceParameterFlags - // Organization context is only required if more than 1 template - // shares the same name across multiple organizations. - orgContext = NewOrganizationContext() - ) - client := new(codersdk.Client) - cmd := &serpent.Command{ - Annotations: workspaceCommand, - Use: "attach [workspace]", - Short: "Create a workspace and attach an external agent to it", - Long: FormatExamples( - Example{ - Description: "Attach an external agent to a workspace", - Command: "coder attach my-workspace --template externally-managed-workspace --output text", - }, - ), - Middleware: serpent.Chain(r.InitClient(client)), - Handler: func(inv *serpent.Invocation) error { - var err error - workspaceOwner := codersdk.Me - if len(inv.Args) >= 1 { - workspaceOwner, workspaceName, err = splitNamedWorkspace(inv.Args[0]) - if err != nil { - return err - } - } - - if workspaceName == "" { - workspaceName, err = cliui.Prompt(inv, cliui.PromptOptions{ - Text: "Specify a name for your workspace:", - Validate: func(workspaceName string) error { - err = codersdk.NameValid(workspaceName) - if err != nil { - return xerrors.Errorf("workspace name %q is invalid: %w", workspaceName, err) - } - _, err = client.WorkspaceByOwnerAndName(inv.Context(), workspaceOwner, workspaceName, codersdk.WorkspaceOptions{}) - if err == nil { - return xerrors.Errorf("a workspace already exists named %q", workspaceName) - } - return nil - }, - }) - if err != nil { - return err - } - } - err = codersdk.NameValid(workspaceName) - if err != nil { - return xerrors.Errorf("workspace name %q is invalid: %w", workspaceName, err) - } - - if workspace, err := client.WorkspaceByOwnerAndName(inv.Context(), workspaceOwner, workspaceName, codersdk.WorkspaceOptions{}); err == nil { - return externalAgentDetails(inv, client, workspace, workspace.LatestBuild.Resources) - } - - // If workspace doesn't exist, create it - var template codersdk.Template - var templateVersionID uuid.UUID - switch { - case templateName == "": - _, _ = fmt.Fprintln(inv.Stdout, pretty.Sprint(cliui.DefaultStyles.Wrap, "Select a template below to preview the provisioned infrastructure:")) - - templates, err := client.Templates(inv.Context(), codersdk.TemplateFilter{}) - if err != nil { - return err - } - - slices.SortFunc(templates, func(a, b codersdk.Template) int { - return slice.Descending(a.ActiveUserCount, b.ActiveUserCount) - }) - - templateNames := make([]string, 0, len(templates)) - templateByName := make(map[string]codersdk.Template, len(templates)) - - // If more than 1 organization exists in the list of templates, - // then include the organization name in the select options. - uniqueOrganizations := make(map[uuid.UUID]bool) - for _, template := range templates { - uniqueOrganizations[template.OrganizationID] = true - } - - for _, template := range templates { - templateName := template.Name - if len(uniqueOrganizations) > 1 { - templateName += cliui.Placeholder( - fmt.Sprintf( - " (%s)", - template.OrganizationName, - ), - ) - } - - if template.ActiveUserCount > 0 { - templateName += cliui.Placeholder( - fmt.Sprintf( - " used by %s", - formatActiveDevelopers(template.ActiveUserCount), - ), - ) - } - - templateNames = append(templateNames, templateName) - templateByName[templateName] = template - } - - // Move the cursor up a single line for nicer display! - option, err := cliui.Select(inv, cliui.SelectOptions{ - Options: templateNames, - HideSearch: true, - }) - if err != nil { - return err - } - - template = templateByName[option] - templateVersionID = template.ActiveVersionID - default: - templates, err := client.Templates(inv.Context(), codersdk.TemplateFilter{ - ExactName: templateName, - }) - if err != nil { - return xerrors.Errorf("get template by name: %w", err) - } - if len(templates) == 0 { - return xerrors.Errorf("no template found with the name %q", templateName) - } - - if len(templates) > 1 { - templateOrgs := []string{} - for _, tpl := range templates { - templateOrgs = append(templateOrgs, tpl.OrganizationName) - } - - selectedOrg, err := orgContext.Selected(inv, client) - if err != nil { - return xerrors.Errorf("multiple templates found with the name %q, use `--org=` to specify which template by that name to use. Organizations available: %s", templateName, strings.Join(templateOrgs, ", ")) - } - - index := slices.IndexFunc(templates, func(i codersdk.Template) bool { - return i.OrganizationID == selectedOrg.ID - }) - if index == -1 { - return xerrors.Errorf("no templates found with the name %q in the organization %q. Templates by that name exist in organizations: %s. Use --org= to select one.", templateName, selectedOrg.Name, strings.Join(templateOrgs, ", ")) - } - - // remake the list with the only template selected - templates = []codersdk.Template{templates[index]} - } - - template = templates[0] - templateVersionID = template.ActiveVersionID - } - - if len(templateVersion) > 0 { - version, err := client.TemplateVersionByName(inv.Context(), template.ID, templateVersion) - if err != nil { - return xerrors.Errorf("get template version by name: %w", err) - } - templateVersionID = version.ID - } - - // If the user specified an organization via a flag or env var, the template **must** - // be in that organization. Otherwise, we should throw an error. - orgValue, orgValueSource := orgContext.ValueSource(inv) - if orgValue != "" && !(orgValueSource == serpent.ValueSourceDefault || orgValueSource == serpent.ValueSourceNone) { - selectedOrg, err := orgContext.Selected(inv, client) - if err != nil { - return err - } - - if template.OrganizationID != selectedOrg.ID { - orgNameFormat := "'--org=%q'" - if orgValueSource == serpent.ValueSourceEnv { - orgNameFormat = "CODER_ORGANIZATION=%q" - } - - return xerrors.Errorf("template is in organization %q, but %s was specified. Use %s to use this template", - template.OrganizationName, - fmt.Sprintf(orgNameFormat, selectedOrg.Name), - fmt.Sprintf(orgNameFormat, template.OrganizationName), - ) - } - } - - cliBuildParameters, err := asWorkspaceBuildParameters(parameterFlags.richParameters) - if err != nil { - return xerrors.Errorf("can't parse given parameter values: %w", err) - } - - cliBuildParameterDefaults, err := asWorkspaceBuildParameters(parameterFlags.richParameterDefaults) - if err != nil { - return xerrors.Errorf("can't parse given parameter defaults: %w", err) - } - - richParameters, resources, err := prepWorkspaceBuild(inv, client, prepWorkspaceBuildArgs{ - Action: WorkspaceCreate, - TemplateVersionID: templateVersionID, - NewWorkspaceName: workspaceName, - - RichParameterFile: parameterFlags.richParameterFile, - RichParameters: cliBuildParameters, - RichParameterDefaults: cliBuildParameterDefaults, - }) - if err != nil { - return xerrors.Errorf("prepare build: %w", err) - } - - _, err = cliui.Prompt(inv, cliui.PromptOptions{ - Text: "Confirm create?", - IsConfirm: true, - }) - if err != nil { - return err - } - - workspace, err := client.CreateUserWorkspace(inv.Context(), workspaceOwner, codersdk.CreateWorkspaceRequest{ - TemplateVersionID: templateVersionID, - Name: workspaceName, - RichParameterValues: richParameters, - }) - if err != nil { - return xerrors.Errorf("create workspace: %w", err) - } - - cliutil.WarnMatchedProvisioners(inv.Stderr, workspace.LatestBuild.MatchedProvisioners, workspace.LatestBuild.Job) - - err = cliui.WorkspaceBuild(inv.Context(), inv.Stdout, client, workspace.LatestBuild.ID) - if err != nil { - return xerrors.Errorf("watch build: %w", err) - } - - _, _ = fmt.Fprintf( - inv.Stdout, - "\nThe %s workspace has been created at %s!\n\n", - cliui.Keyword(workspace.Name), - cliui.Timestamp(time.Now()), - ) - - return externalAgentDetails(inv, client, workspace, resources) - }, - } - - cmd.Options = serpent.OptionSet{ - serpent.Option{ - Flag: "template", - FlagShorthand: "t", - Env: "CODER_TEMPLATE_NAME", - Description: "Specify a template name.", - Value: serpent.StringOf(&templateName), - }, - serpent.Option{ - Flag: "template-version", - Env: "CODER_TEMPLATE_VERSION", - Description: "Specify a template version name.", - Value: serpent.StringOf(&templateVersion), - }, - cliui.SkipPromptOption(), - } - cmd.Options = append(cmd.Options, parameterFlags.cliParameters()...) - cmd.Options = append(cmd.Options, parameterFlags.cliParameterDefaults()...) - orgContext.AttachOptions(cmd) - return cmd -} - -func externalAgentDetails(inv *serpent.Invocation, client *codersdk.Client, workspace codersdk.Workspace, resources []codersdk.WorkspaceResource) error { - if len(resources) == 0 { - return xerrors.Errorf("no resources found for workspace") - } - - for _, resource := range resources { - if resource.Type == "coder_external_agent" { - agent := resource.Agents[0] - credential, err := client.WorkspaceExternalAgentCredential(inv.Context(), workspace.ID, agent.Name) - if err != nil { - return xerrors.Errorf("get external agent token: %w", err) - } - - initScriptURL := fmt.Sprintf("%s/api/v2/init-script", client.URL) - if agent.OperatingSystem != "linux" || agent.Architecture != "amd64" { - initScriptURL = fmt.Sprintf("%s/api/v2/init-script?os=%s&arch=%s", client.URL, agent.OperatingSystem, agent.Architecture) - } - - _, _ = fmt.Fprintf(inv.Stdout, "Please run the following commands to attach an agent to the workspace %s:\n", cliui.Keyword(workspace.Name)) - _, _ = fmt.Fprintf(inv.Stdout, "%s\n", pretty.Sprint(cliui.DefaultStyles.Code, fmt.Sprintf("export CODER_AGENT_TOKEN=%s", credential.AgentToken))) - _, _ = fmt.Fprintf(inv.Stdout, "%s\n", pretty.Sprint(cliui.DefaultStyles.Code, fmt.Sprintf("curl -fsSL %s | sh", initScriptURL))) - } - } - - return nil -} diff --git a/cli/external_workspaces.go b/cli/external_workspaces.go new file mode 100644 index 0000000000000..2e8ffcd1b87a4 --- /dev/null +++ b/cli/external_workspaces.go @@ -0,0 +1,260 @@ +package cli + +import ( + "fmt" + "strings" + + "golang.org/x/xerrors" + + "github.com/google/uuid" + + "github.com/coder/coder/v2/cli/cliui" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/pretty" + "github.com/coder/serpent" +) + +type externalAgent struct { + AgentName string `json:"-"` + AuthType string `json:"auth_type"` + AuthToken string `json:"auth_token"` + InitScript string `json:"init_script"` +} + +func (r *RootCmd) externalWorkspaces() *serpent.Command { + orgContext := NewOrganizationContext() + + cmd := &serpent.Command{ + Use: "external-workspaces [subcommand]", + Short: "External workspace related commands", + Handler: func(inv *serpent.Invocation) error { + return inv.Command.HelpHandler(inv) + }, + Children: []*serpent.Command{ + r.externalWorkspaceCreate(), + r.externalWorkspaceAgentInstructions(), + }, + } + + orgContext.AttachOptions(cmd) + return cmd +} + +// externalWorkspaceCreate extends `coder create` to create an external workspace. +func (r *RootCmd) externalWorkspaceCreate() *serpent.Command { + var ( + orgContext = NewOrganizationContext() + client = new(codersdk.Client) + ) + + cmd := r.create() + cmd.Use = "create [workspace]" + cmd.Short = "Create a new external workspace" + cmd.Middleware = serpent.Chain( + cmd.Middleware, + r.InitClient(client), + serpent.RequireNArgs(1), + ) + + createHandler := cmd.Handler + cmd.Handler = func(inv *serpent.Invocation) error { + workspaceName := inv.Args[0] + templateVersion := inv.ParsedFlags().Lookup("template-version") + templateName := inv.ParsedFlags().Lookup("template") + if templateName == nil || templateName.Value.String() == "" { + return xerrors.Errorf("template name is required for external workspace creation. Use --template=") + } + + organization, err := orgContext.Selected(inv, client) + if err != nil { + return xerrors.Errorf("get current organization: %w", err) + } + + template, err := client.TemplateByName(inv.Context(), organization.ID, templateName.Value.String()) + if err != nil { + return xerrors.Errorf("get template by name: %w", err) + } + + var resources []codersdk.WorkspaceResource + var templateVersionID uuid.UUID + if templateVersion == nil || templateVersion.Value.String() == "" { + templateVersionID = template.ActiveVersionID + } else { + version, err := client.TemplateVersionByName(inv.Context(), template.ID, templateVersion.Value.String()) + if err != nil { + return xerrors.Errorf("get template version by name: %w", err) + } + templateVersionID = version.ID + } + + resources, err = client.TemplateVersionResources(inv.Context(), templateVersionID) + if err != nil { + return xerrors.Errorf("get template version resources: %w", err) + } + if len(resources) == 0 { + return xerrors.Errorf("no resources found for template version %q", templateVersion.Value.String()) + } + + var hasExternalAgent bool + for _, resource := range resources { + if resource.Type == "coder_external_agent" { + hasExternalAgent = true + break + } + } + + if !hasExternalAgent { + return xerrors.Errorf("template version %q does not have an external agent. Only templates with external agents can be used for external workspace creation", templateVersion.Value.String()) + } + + err = createHandler(inv) + if err != nil { + return err + } + + workspace, err := client.WorkspaceByOwnerAndName(inv.Context(), codersdk.Me, workspaceName, codersdk.WorkspaceOptions{}) + if err != nil { + return xerrors.Errorf("get workspace by name: %w", err) + } + + externalAgents, err := fetchExternalAgents(inv, client, workspace, workspace.LatestBuild.Resources) + if err != nil { + return xerrors.Errorf("fetch external agents: %w", err) + } + + return printExternalAgents(inv, workspace.Name, externalAgents) + } + return cmd +} + +// externalWorkspaceAgentInstructions prints the instructions for an external agent. +func (r *RootCmd) externalWorkspaceAgentInstructions() *serpent.Command { + client := new(codersdk.Client) + formatter := cliui.NewOutputFormatter( + cliui.ChangeFormatterData(cliui.TextFormat(), func(data any) (any, error) { + agent, ok := data.(externalAgent) + if !ok { + return "", xerrors.Errorf("expected externalAgent, got %T", data) + } + + var output strings.Builder + output.WriteString(fmt.Sprintf("Please run the following commands to attach agent %s:\n", cliui.Keyword(agent.AgentName))) + output.WriteString(fmt.Sprintf("%s\n", pretty.Sprint(cliui.DefaultStyles.Code, fmt.Sprintf("export CODER_AGENT_TOKEN=%s", agent.AuthToken)))) + output.WriteString(pretty.Sprint(cliui.DefaultStyles.Code, fmt.Sprintf("curl -fsSL %s | sh", agent.InitScript))) + + return output.String(), nil + }), + cliui.JSONFormat(), + ) + + cmd := &serpent.Command{ + Use: "agent-instructions [workspace name] [agent name]", + Short: "Get the instructions for an external agent", + Middleware: serpent.Chain(r.InitClient(client), serpent.RequireNArgs(2)), + Handler: func(inv *serpent.Invocation) error { + workspaceName := inv.Args[0] + agentName := inv.Args[1] + + workspace, err := client.WorkspaceByOwnerAndName(inv.Context(), codersdk.Me, workspaceName, codersdk.WorkspaceOptions{}) + if err != nil { + return xerrors.Errorf("get workspace by name: %w", err) + } + + credential, err := client.WorkspaceExternalAgentCredential(inv.Context(), workspace.ID, agentName) + if err != nil { + return xerrors.Errorf("get external agent token for agent %q: %w", agentName, err) + } + + var agent codersdk.WorkspaceAgent + for _, resource := range workspace.LatestBuild.Resources { + for _, a := range resource.Agents { + if a.Name == agentName { + agent = a + break + } + } + if agent.ID != uuid.Nil { + break + } + } + + initScriptURL := fmt.Sprintf("%s/api/v2/init-script", client.URL) + if agent.OperatingSystem != "linux" || agent.Architecture != "amd64" { + initScriptURL = fmt.Sprintf("%s/api/v2/init-script?os=%s&arch=%s", client.URL, agent.OperatingSystem, agent.Architecture) + } + + agentInfo := externalAgent{ + AgentName: agentName, + AuthType: "token", + AuthToken: credential.AgentToken, + InitScript: initScriptURL, + } + + out, err := formatter.Format(inv.Context(), agentInfo) + if err != nil { + return err + } + + _, err = fmt.Fprintln(inv.Stdout, out) + return err + }, + } + + formatter.AttachOptions(&cmd.Options) + return cmd +} + +// fetchExternalAgents fetches the external agents for a workspace. +func fetchExternalAgents(inv *serpent.Invocation, client *codersdk.Client, workspace codersdk.Workspace, resources []codersdk.WorkspaceResource) ([]externalAgent, error) { + if len(resources) == 0 { + return nil, xerrors.Errorf("no resources found for workspace") + } + + var externalAgents []externalAgent + + for _, resource := range resources { + if resource.Type != "coder_external_agent" || len(resource.Agents) == 0 { + continue + } + + agent := resource.Agents[0] + credential, err := client.WorkspaceExternalAgentCredential(inv.Context(), workspace.ID, agent.Name) + if err != nil { + return nil, xerrors.Errorf("get external agent token for agent %q: %w", agent.Name, err) + } + + initScriptURL := fmt.Sprintf("%s/api/v2/init-script", client.URL) + if agent.OperatingSystem != "linux" || agent.Architecture != "amd64" { + initScriptURL = fmt.Sprintf("%s/api/v2/init-script?os=%s&arch=%s", client.URL, agent.OperatingSystem, agent.Architecture) + } + + externalAgents = append(externalAgents, externalAgent{ + AgentName: agent.Name, + AuthType: "token", + AuthToken: credential.AgentToken, + InitScript: initScriptURL, + }) + } + + return externalAgents, nil +} + +// printExternalAgents prints the instructions for an external agent. +func printExternalAgents(inv *serpent.Invocation, workspaceName string, externalAgents []externalAgent) error { + fmt.Fprintf(inv.Stdout, "\nPlease run the following commands to attach external agent to the workspace %s:\n\n", cliui.Keyword(workspaceName)) + + for i, agent := range externalAgents { + if len(externalAgents) > 1 { + fmt.Fprintf(inv.Stdout, "For agent %s:\n", cliui.Keyword(agent.AgentName)) + } + + fmt.Fprintf(inv.Stdout, "%s\n", pretty.Sprint(cliui.DefaultStyles.Code, fmt.Sprintf("export CODER_AGENT_TOKEN=%s", agent.AuthToken))) + fmt.Fprintf(inv.Stdout, "%s\n", pretty.Sprint(cliui.DefaultStyles.Code, fmt.Sprintf("curl -fsSL %s | sh", agent.InitScript))) + + if i < len(externalAgents)-1 { + fmt.Fprintf(inv.Stdout, "\n") + } + } + + return nil +} diff --git a/cli/root.go b/cli/root.go index eb1d9441c454e..4e74fe6b5f782 100644 --- a/cli/root.go +++ b/cli/root.go @@ -128,6 +128,9 @@ func (r *RootCmd) CoreSubcommands() []*serpent.Command { r.update(), r.whoami(), + // External Workspace Commands + r.externalWorkspaces(), + // Hidden r.connectCmd(), r.expCmd(), diff --git a/cli/testdata/coder_--help.golden b/cli/testdata/coder_--help.golden index e5e3dfa7762ba..d800bd4e163d3 100644 --- a/cli/testdata/coder_--help.golden +++ b/cli/testdata/coder_--help.golden @@ -14,55 +14,58 @@ USAGE: $ coder templates init SUBCOMMANDS: - attach Create a workspace and attach an external agent to it - 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 + attach Create a workspace and attach an external agent to it + 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 External workspace related commands + 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..8d3eed2f3b00b --- /dev/null +++ b/cli/testdata/coder_external-workspaces_--help.golden @@ -0,0 +1,17 @@ +coder v0.0.0-devel + +USAGE: + coder external-workspaces [flags] [subcommand] + + External workspace related commands + +SUBCOMMANDS: + agent-instructions Get the instructions for an external agent + create Create a new external workspace + +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..99d18a82bf73e --- /dev/null +++ b/cli/testdata/coder_external-workspaces_agent-instructions_--help.golden @@ -0,0 +1,14 @@ +coder v0.0.0-devel + +USAGE: + coder external-workspaces agent-instructions [flags] [workspace name] [agent + name] + + 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..f2ebc96fa6d98 --- /dev/null +++ b/cli/testdata/coder_external-workspaces_create_--help.golden @@ -0,0 +1,52 @@ +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". + + --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/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 7285bf8cb0d99..9b05bc17b78f9 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -10258,7 +10258,7 @@ const docTemplate = `{ } } }, - "/workspaces/{workspace}/external-agent/{agent}/credential": { + "/workspaces/{workspace}/external-agent/{agent}/credentials": { "get": { "security": [ { @@ -10294,7 +10294,7 @@ const docTemplate = `{ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/codersdk.ExternalAgentCredential" + "$ref": "#/definitions/codersdk.ExternalAgentCredentials" } } } @@ -12908,7 +12908,7 @@ const docTemplate = `{ "ExperimentMCPServerHTTP" ] }, - "codersdk.ExternalAgentCredential": { + "codersdk.ExternalAgentCredentials": { "type": "object", "properties": { "agent_token": { diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 1171e91f4b33f..fe7d01c901364 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -9074,7 +9074,7 @@ } } }, - "/workspaces/{workspace}/external-agent/{agent}/credential": { + "/workspaces/{workspace}/external-agent/{agent}/credentials": { "get": { "security": [ { @@ -9106,7 +9106,7 @@ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/codersdk.ExternalAgentCredential" + "$ref": "#/definitions/codersdk.ExternalAgentCredentials" } } } @@ -11574,7 +11574,7 @@ "ExperimentMCPServerHTTP" ] }, - "codersdk.ExternalAgentCredential": { + "codersdk.ExternalAgentCredentials": { "type": "object", "properties": { "agent_token": { diff --git a/coderd/coderd.go b/coderd/coderd.go index a7b8f6d49930d..7129ad0806186 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -1413,7 +1413,7 @@ func New(options *Options) *API { r.Delete("/", api.deleteWorkspaceAgentPortShare) }) r.Route("/external-agent", func(r chi.Router) { - r.Get("/{agent}/credential", api.workspaceExternalAgentCredential) + r.Get("/{agent}/credentials", api.workspaceExternalAgentCredentials) }) r.Get("/timings", api.workspaceTimings) }) diff --git a/coderd/workspaceagents.go b/coderd/workspaceagents.go index 7fb74da5859eb..697a37d242870 100644 --- a/coderd/workspaceagents.go +++ b/coderd/workspaceagents.go @@ -2189,9 +2189,9 @@ func convertWorkspaceAgentLog(logEntry database.WorkspaceAgentLog) codersdk.Work // @Tags Agents // @Param workspace path string true "Workspace ID" format(uuid) // @Param agent path string true "Agent name" -// @Success 200 {object} codersdk.ExternalAgentCredential -// @Router /workspaces/{workspace}/external-agent/{agent}/credential [get] -func (api *API) workspaceExternalAgentCredential(rw http.ResponseWriter, r *http.Request) { +// @Success 200 {object} codersdk.ExternalAgentCredentials +// @Router /workspaces/{workspace}/external-agent/{agent}/credentials [get] +func (api *API) workspaceExternalAgentCredentials(rw http.ResponseWriter, r *http.Request) { ctx := r.Context() workspace := httpmw.WorkspaceParam(r) agentName := chi.URLParam(r, "agent") @@ -2219,7 +2219,7 @@ func (api *API) workspaceExternalAgentCredential(rw http.ResponseWriter, r *http for _, agent := range agents { if agent.Name == agentName { - httpapi.Write(ctx, rw, http.StatusOK, codersdk.ExternalAgentCredential{ + httpapi.Write(ctx, rw, http.StatusOK, codersdk.ExternalAgentCredentials{ AgentToken: agent.AuthToken.String(), }) return diff --git a/codersdk/workspaces.go b/codersdk/workspaces.go index d8c1be732c72c..37cec0011f022 100644 --- a/codersdk/workspaces.go +++ b/codersdk/workspaces.go @@ -663,21 +663,21 @@ func (c *Client) WorkspaceTimings(ctx context.Context, id uuid.UUID) (WorkspaceB return timings, json.NewDecoder(res.Body).Decode(&timings) } -// ExternalAgentCredential contains the credential needed for an external agent to connect to Coder. -type ExternalAgentCredential struct { +// ExternalAgentCredentials contains the credentials needed for an external agent to connect to Coder. +type ExternalAgentCredentials struct { AgentToken string `json:"agent_token"` } -func (c *Client) WorkspaceExternalAgentCredential(ctx context.Context, workspaceID uuid.UUID, agentName string) (ExternalAgentCredential, error) { - path := fmt.Sprintf("/api/v2/workspaces/%s/external-agent/%s/credential", workspaceID.String(), agentName) +func (c *Client) WorkspaceExternalAgentCredential(ctx context.Context, workspaceID uuid.UUID, agentName string) (ExternalAgentCredentials, error) { + path := fmt.Sprintf("/api/v2/workspaces/%s/external-agent/%s/credentials", workspaceID.String(), agentName) res, err := c.Request(ctx, http.MethodGet, path, nil) if err != nil { - return ExternalAgentCredential{}, err + return ExternalAgentCredentials{}, err } defer res.Body.Close() if res.StatusCode != http.StatusOK { - return ExternalAgentCredential{}, ReadBodyAsError(res) + return ExternalAgentCredentials{}, ReadBodyAsError(res) } - var credential ExternalAgentCredential - return credential, json.NewDecoder(res.Body).Decode(&credential) + var credentials ExternalAgentCredentials + return credentials, json.NewDecoder(res.Body).Decode(&credentials) } diff --git a/docs/manifest.json b/docs/manifest.json index d115abe1ecdba..0625819213e68 100644 --- a/docs/manifest.json +++ b/docs/manifest.json @@ -1160,6 +1160,21 @@ "description": "Print auth for an external provider", "path": "reference/cli/external-auth_access-token.md" }, + { + "title": "external-workspaces", + "description": "External workspace related commands", + "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": "favorite", "description": "Add a workspace to your favorites", diff --git a/docs/reference/api/agents.md b/docs/reference/api/agents.md index 4f61b8d967ad9..5cbc76dc63178 100644 --- a/docs/reference/api/agents.md +++ b/docs/reference/api/agents.md @@ -1245,12 +1245,12 @@ To perform this operation, you must be authenticated. [Learn more](authenticatio ```shell # Example request using curl -curl -X GET http://coder-server:8080/api/v2/workspaces/{workspace}/external-agent/{agent}/credential \ +curl -X GET http://coder-server:8080/api/v2/workspaces/{workspace}/external-agent/{agent}/credentials \ -H 'Accept: application/json' \ -H 'Coder-Session-Token: API_KEY' ``` -`GET /workspaces/{workspace}/external-agent/{agent}/credential` +`GET /workspaces/{workspace}/external-agent/{agent}/credentials` ### Parameters @@ -1271,8 +1271,8 @@ curl -X GET http://coder-server:8080/api/v2/workspaces/{workspace}/external-agen ### Responses -| Status | Meaning | Description | Schema | -|--------|---------------------------------------------------------|-------------|--------------------------------------------------------------------------------| -| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [codersdk.ExternalAgentCredential](schemas.md#codersdkexternalagentcredential) | +| Status | Meaning | Description | Schema | +|--------|---------------------------------------------------------|-------------|----------------------------------------------------------------------------------| +| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [codersdk.ExternalAgentCredentials](schemas.md#codersdkexternalagentcredentials) | To perform this operation, you must be authenticated. [Learn more](authentication.md). diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index d73bdc12353ba..227422483d4f0 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -3304,7 +3304,7 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o | `oauth2` | | `mcp-server-http` | -## codersdk.ExternalAgentCredential +## codersdk.ExternalAgentCredentials ```json { diff --git a/docs/reference/cli/external-workspaces.md b/docs/reference/cli/external-workspaces.md new file mode 100644 index 0000000000000..ccc1546e1bd00 --- /dev/null +++ b/docs/reference/cli/external-workspaces.md @@ -0,0 +1,28 @@ + +# external-workspaces + +External workspace related commands + +## 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 | + +## 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..5285bd27ade24 --- /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] [workspace name] [agent name] +``` + +## 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..8694b763540d4 --- /dev/null +++ b/docs/reference/cli/external-workspaces_create.md @@ -0,0 +1,119 @@ + +# 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. + +### --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/index.md b/docs/reference/cli/index.md index 2757119c46813..094eb01fe05e8 100644 --- a/docs/reference/cli/index.md +++ b/docs/reference/cli/index.md @@ -22,52 +22,53 @@ 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 | -| [attach](./attach.md) | Create a workspace and attach an external agent to it | -| [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 | +| [attach](./attach.md) | Create a workspace and attach an external agent to it | +| [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) | External workspace related commands | +| [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 diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index b490de16041b3..0bf716efe80b4 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -924,7 +924,7 @@ export const Experiments: Experiment[] = [ ]; // From codersdk/workspaces.go -export interface ExternalAgentCredential { +export interface ExternalAgentCredentials { readonly agent_token: string; } pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy