From 3ea541eef34072d1eacd7e2455167f193249a1b9 Mon Sep 17 00:00:00 2001 From: Kacper Sawicki Date: Wed, 23 Jul 2025 11:10:43 +0000 Subject: [PATCH 01/28] 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 02/28] 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 03/28] 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; } From 1044051ac44234328945989bacccb365c83303eb Mon Sep 17 00:00:00 2001 From: Kacper Sawicki Date: Tue, 29 Jul 2025 08:11:46 +0000 Subject: [PATCH 04/28] add has_external_agent to workspace builds --- cli/root.go | 1 - cli/testdata/coder_--help.golden | 1 - coderd/apidoc/docs.go | 5 +- coderd/apidoc/swagger.json | 5 +- coderd/database/dbauthz/dbauthz.go | 22 +- coderd/database/dbmetrics/querymetrics.go | 20 +- coderd/database/dbmock/dbmock.go | 26 +- coderd/database/dump.sql | 6 +- .../000351_external_agents.down.sql | 49 +++- .../migrations/000351_external_agents.up.sql | 57 ++++- coderd/database/modelqueries.go | 4 +- coderd/database/models.go | 14 +- coderd/database/querier.go | 3 +- coderd/database/queries.sql.go | 223 +++++++++++------- coderd/database/queries/templates.sql | 6 +- coderd/database/queries/templateversions.sql | 4 +- coderd/database/queries/workspacebuilds.sql | 8 + coderd/database/queries/workspaces.sql | 13 +- .../provisionerdserver/provisionerdserver.go | 24 +- coderd/searchquery/search.go | 17 +- coderd/workspaces.go | 2 +- codersdk/workspacebuilds.go | 1 + docs/admin/security/audit-logs.md | 4 +- docs/manifest.json | 5 - docs/reference/api/builds.md | 6 + docs/reference/api/schemas.md | 4 + docs/reference/api/workspaces.md | 16 +- docs/reference/cli/attach.md | 82 ------- docs/reference/cli/index.md | 1 - enterprise/audit/table.go | 3 +- site/src/api/typesGenerated.ts | 1 + 31 files changed, 400 insertions(+), 233 deletions(-) delete mode 100644 docs/reference/cli/attach.md diff --git a/cli/root.go b/cli/root.go index 4e74fe6b5f782..57d6ff4d16d35 100644 --- a/cli/root.go +++ b/cli/root.go @@ -106,7 +106,6 @@ func (r *RootCmd) CoreSubcommands() []*serpent.Command { r.version(defaultVersionInfo), // Workspace Commands - r.attach(), r.autoupdate(), r.configSSH(), r.create(), diff --git a/cli/testdata/coder_--help.golden b/cli/testdata/coder_--help.golden index d800bd4e163d3..3b4b41a0d3b2d 100644 --- a/cli/testdata/coder_--help.golden +++ b/cli/testdata/coder_--help.golden @@ -14,7 +14,6 @@ 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/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 9b05bc17b78f9..1e475206f7294 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -9866,7 +9866,7 @@ const docTemplate = `{ "parameters": [ { "type": "string", - "description": "Search query in the format ` + "`" + `key:value` + "`" + `. Available keys are: owner, template, name, status, has-agent, dormant, last_used_after, last_used_before, has-ai-task.", + "description": "Search query in the format ` + "`" + `key:value` + "`" + `. Available keys are: owner, template, name, status, has-agent, dormant, last_used_after, last_used_before, has-ai-task, has-external-agent.", "name": "q", "in": "query" }, @@ -18653,6 +18653,9 @@ const docTemplate = `{ "has_ai_task": { "type": "boolean" }, + "has_external_agent": { + "type": "boolean" + }, "id": { "type": "string", "format": "uuid" diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index fe7d01c901364..bb91a9798e55a 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -8720,7 +8720,7 @@ "parameters": [ { "type": "string", - "description": "Search query in the format `key:value`. Available keys are: owner, template, name, status, has-agent, dormant, last_used_after, last_used_before, has-ai-task.", + "description": "Search query in the format `key:value`. Available keys are: owner, template, name, status, has-agent, dormant, last_used_after, last_used_before, has-ai-task, has-external-agent.", "name": "q", "in": "query" }, @@ -17061,6 +17061,9 @@ "has_ai_task": { "type": "boolean" }, + "has_external_agent": { + "type": "boolean" + }, "id": { "type": "string", "format": "uuid" diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index ba27fee5340a1..5b33ac3d216c2 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -4695,7 +4695,7 @@ 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 { +func (q *querier) UpdateTemplateVersionExternalAgentByJobID(ctx context.Context, arg database.UpdateTemplateVersionExternalAgentByJobIDParams) 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 { @@ -4714,7 +4714,7 @@ func (q *querier) UpdateTemplateVersionExternalAgentsByJobID(ctx context.Context if err := q.authorizeContext(ctx, policy.ActionUpdate, obj); err != nil { return err } - return q.db.UpdateTemplateVersionExternalAgentsByJobID(ctx, arg) + return q.db.UpdateTemplateVersionExternalAgentByJobID(ctx, arg) } func (q *querier) UpdateTemplateVersionExternalAuthProvidersByJobID(ctx context.Context, arg database.UpdateTemplateVersionExternalAuthProvidersByJobIDParams) error { @@ -5073,6 +5073,24 @@ func (q *querier) UpdateWorkspaceBuildDeadlineByID(ctx context.Context, arg data return q.db.UpdateWorkspaceBuildDeadlineByID(ctx, arg) } +func (q *querier) UpdateWorkspaceBuildExternalAgentByID(ctx context.Context, arg database.UpdateWorkspaceBuildExternalAgentByIDParams) error { + build, err := q.db.GetWorkspaceBuildByID(ctx, arg.ID) + if err != nil { + return err + } + + workspace, err := q.db.GetWorkspaceByID(ctx, build.WorkspaceID) + if err != nil { + return err + } + + err = q.authorizeContext(ctx, policy.ActionUpdate, workspace.RBACObject()) + if err != nil { + return err + } + return q.db.UpdateWorkspaceBuildExternalAgentByID(ctx, arg) +} + func (q *querier) UpdateWorkspaceBuildProvisionerStateByID(ctx context.Context, arg database.UpdateWorkspaceBuildProvisionerStateByIDParams) error { if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceSystem); err != nil { return err diff --git a/coderd/database/dbmetrics/querymetrics.go b/coderd/database/dbmetrics/querymetrics.go index 6e8b93182144b..22aaf7e74bc67 100644 --- a/coderd/database/dbmetrics/querymetrics.go +++ b/coderd/database/dbmetrics/querymetrics.go @@ -89,6 +89,13 @@ func (m queryMetricsStore) DeleteOrganization(ctx context.Context, id uuid.UUID) return r0 } +func (m queryMetricsStore) UpdateTemplateVersionExternalAgentsByJobID(ctx context.Context, arg database.UpdateTemplateVersionExternalAgentByJobIDParams) error { + start := time.Now() + r0 := m.s.UpdateTemplateVersionExternalAgentByJobID(ctx, arg) + m.queryLatencies.WithLabelValues("UpdateTemplateVersionExternalAgentsByJobID").Observe(time.Since(start).Seconds()) + return r0 +} + func (m queryMetricsStore) AcquireLock(ctx context.Context, pgAdvisoryXactLock int64) error { start := time.Now() err := m.s.AcquireLock(ctx, pgAdvisoryXactLock) @@ -2882,10 +2889,10 @@ func (m queryMetricsStore) UpdateTemplateVersionDescriptionByJobID(ctx context.C return err } -func (m queryMetricsStore) UpdateTemplateVersionExternalAgentsByJobID(ctx context.Context, arg database.UpdateTemplateVersionExternalAgentsByJobIDParams) error { +func (m queryMetricsStore) UpdateTemplateVersionExternalAgentByJobID(ctx context.Context, arg database.UpdateTemplateVersionExternalAgentByJobIDParams) error { start := time.Now() - r0 := m.s.UpdateTemplateVersionExternalAgentsByJobID(ctx, arg) - m.queryLatencies.WithLabelValues("UpdateTemplateVersionExternalAgentsByJobID").Observe(time.Since(start).Seconds()) + r0 := m.s.UpdateTemplateVersionExternalAgentByJobID(ctx, arg) + m.queryLatencies.WithLabelValues("UpdateTemplateVersionExternalAgentByJobID").Observe(time.Since(start).Seconds()) return r0 } @@ -3099,6 +3106,13 @@ func (m queryMetricsStore) UpdateWorkspaceBuildDeadlineByID(ctx context.Context, return r0 } +func (m queryMetricsStore) UpdateWorkspaceBuildExternalAgentByID(ctx context.Context, arg database.UpdateWorkspaceBuildExternalAgentByIDParams) error { + start := time.Now() + r0 := m.s.UpdateWorkspaceBuildExternalAgentByID(ctx, arg) + m.queryLatencies.WithLabelValues("UpdateWorkspaceBuildExternalAgentByID").Observe(time.Since(start).Seconds()) + return r0 +} + func (m queryMetricsStore) UpdateWorkspaceBuildProvisionerStateByID(ctx context.Context, arg database.UpdateWorkspaceBuildProvisionerStateByIDParams) error { start := time.Now() r0 := m.s.UpdateWorkspaceBuildProvisionerStateByID(ctx, arg) diff --git a/coderd/database/dbmock/dbmock.go b/coderd/database/dbmock/dbmock.go index 2d975e23fb45c..e3839908c4646 100644 --- a/coderd/database/dbmock/dbmock.go +++ b/coderd/database/dbmock/dbmock.go @@ -6155,18 +6155,18 @@ 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 { +// UpdateTemplateVersionExternalAgentByJobID mocks base method. +func (m *MockStore) UpdateTemplateVersionExternalAgentByJobID(ctx context.Context, arg database.UpdateTemplateVersionExternalAgentByJobIDParams) error { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "UpdateTemplateVersionExternalAgentsByJobID", ctx, arg) + ret := m.ctrl.Call(m, "UpdateTemplateVersionExternalAgentByJobID", ctx, arg) ret0, _ := ret[0].(error) return ret0 } -// UpdateTemplateVersionExternalAgentsByJobID indicates an expected call of UpdateTemplateVersionExternalAgentsByJobID. -func (mr *MockStoreMockRecorder) UpdateTemplateVersionExternalAgentsByJobID(ctx, arg any) *gomock.Call { +// UpdateTemplateVersionExternalAgentByJobID indicates an expected call of UpdateTemplateVersionExternalAgentByJobID. +func (mr *MockStoreMockRecorder) UpdateTemplateVersionExternalAgentByJobID(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) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateTemplateVersionExternalAgentByJobID", reflect.TypeOf((*MockStore)(nil).UpdateTemplateVersionExternalAgentByJobID), ctx, arg) } // UpdateTemplateVersionExternalAuthProvidersByJobID mocks base method. @@ -6601,6 +6601,20 @@ func (mr *MockStoreMockRecorder) UpdateWorkspaceBuildDeadlineByID(ctx, arg any) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateWorkspaceBuildDeadlineByID", reflect.TypeOf((*MockStore)(nil).UpdateWorkspaceBuildDeadlineByID), ctx, arg) } +// UpdateWorkspaceBuildExternalAgentByID mocks base method. +func (m *MockStore) UpdateWorkspaceBuildExternalAgentByID(ctx context.Context, arg database.UpdateWorkspaceBuildExternalAgentByIDParams) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpdateWorkspaceBuildExternalAgentByID", ctx, arg) + ret0, _ := ret[0].(error) + return ret0 +} + +// UpdateWorkspaceBuildExternalAgentByID indicates an expected call of UpdateWorkspaceBuildExternalAgentByID. +func (mr *MockStoreMockRecorder) UpdateWorkspaceBuildExternalAgentByID(ctx, arg any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateWorkspaceBuildExternalAgentByID", reflect.TypeOf((*MockStore)(nil).UpdateWorkspaceBuildExternalAgentByID), ctx, arg) +} + // UpdateWorkspaceBuildProvisionerStateByID mocks base method. func (m *MockStore) UpdateWorkspaceBuildProvisionerStateByID(ctx context.Context, arg database.UpdateWorkspaceBuildProvisionerStateByIDParams) error { m.ctrl.T.Helper() diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index 219a75d1850f3..f2b0483a5e95a 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -1671,7 +1671,7 @@ CREATE TABLE template_versions ( archived boolean DEFAULT false NOT NULL, source_example_id text, has_ai_task boolean, - has_external_agents boolean + has_external_agent boolean ); COMMENT ON COLUMN template_versions.external_auth_providers IS 'IDs of External auth providers for a specific template version'; @@ -1702,7 +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, + template_versions.has_external_agent, 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 @@ -2206,6 +2206,7 @@ CREATE TABLE workspace_builds ( template_version_preset_id uuid, has_ai_task boolean, ai_task_sidebar_app_id uuid, + has_external_agent boolean, CONSTRAINT workspace_builds_ai_task_sidebar_app_id_required CHECK (((((has_ai_task IS NULL) OR (has_ai_task = false)) AND (ai_task_sidebar_app_id IS NULL)) OR ((has_ai_task = true) AND (ai_task_sidebar_app_id IS NOT NULL)))) ); @@ -2227,6 +2228,7 @@ CREATE VIEW workspace_build_with_user AS workspace_builds.template_version_preset_id, workspace_builds.has_ai_task, workspace_builds.ai_task_sidebar_app_id, + workspace_builds.has_external_agent, COALESCE(visible_users.avatar_url, ''::text) AS initiator_by_avatar_url, COALESCE(visible_users.username, ''::text) AS initiator_by_username, COALESCE(visible_users.name, ''::text) AS initiator_by_name diff --git a/coderd/database/migrations/000351_external_agents.down.sql b/coderd/database/migrations/000351_external_agents.down.sql index 60fd37107d04b..196a3b1675513 100644 --- a/coderd/database/migrations/000351_external_agents.down.sql +++ b/coderd/database/migrations/000351_external_agents.down.sql @@ -1,4 +1,6 @@ -ALTER TABLE template_versions DROP COLUMN has_external_agents; +ALTER TABLE template_versions DROP COLUMN has_external_agent; + +DROP VIEW template_version_with_user; -- Recreate `template_version_with_user` as defined in dump.sql CREATE VIEW template_version_with_user AS @@ -29,3 +31,48 @@ FROM ); COMMENT ON VIEW template_version_with_user IS 'Joins in the username + avatar url of the created by user.'; + +ALTER TABLE workspace_builds DROP COLUMN has_external_agent; + +DROP VIEW workspace_build_with_user; + +-- We're adding the has_external_agent column. +CREATE VIEW workspace_build_with_user AS +SELECT + workspace_builds.id, + workspace_builds.created_at, + workspace_builds.updated_at, + workspace_builds.workspace_id, + workspace_builds.template_version_id, + workspace_builds.build_number, + workspace_builds.transition, + workspace_builds.initiator_id, + workspace_builds.provisioner_state, + workspace_builds.job_id, + workspace_builds.deadline, + workspace_builds.reason, + workspace_builds.daily_cost, + workspace_builds.max_deadline, + workspace_builds.template_version_preset_id, + workspace_builds.has_ai_task, + workspace_builds.ai_task_sidebar_app_id, + COALESCE( + visible_users.avatar_url, + '' :: text + ) AS initiator_by_avatar_url, + COALESCE( + visible_users.username, + '' :: text + ) AS initiator_by_username, + COALESCE(visible_users.name, '' :: text) AS initiator_by_name +FROM + ( + workspace_builds + LEFT JOIN visible_users ON ( + ( + workspace_builds.initiator_id = visible_users.id + ) + ) + ); + +COMMENT ON VIEW workspace_build_with_user IS 'Joins in the username + avatar url of the initiated by user.'; diff --git a/coderd/database/migrations/000351_external_agents.up.sql b/coderd/database/migrations/000351_external_agents.up.sql index 49ba11bf7724a..7306d2664c375 100644 --- a/coderd/database/migrations/000351_external_agents.up.sql +++ b/coderd/database/migrations/000351_external_agents.up.sql @@ -1,8 +1,8 @@ --- Determines if a coder_ai_task resource is defined in a template version. +-- Determines if a coder_external_agent resource is defined in a template version. ALTER TABLE template_versions ADD - COLUMN has_external_agents BOOLEAN; + COLUMN has_external_agent BOOLEAN; DROP VIEW template_version_with_user; @@ -23,7 +23,7 @@ SELECT template_versions.archived, template_versions.source_example_id, template_versions.has_ai_task, - template_versions.has_external_agents, + template_versions.has_external_agent, 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 @@ -36,3 +36,54 @@ FROM ); COMMENT ON VIEW template_version_with_user IS 'Joins in the username + avatar url of the created by user.'; + +-- Determines if a coder_external_agent resource was included in a +-- workspace build. +ALTER TABLE + workspace_builds +ADD + COLUMN has_external_agent BOOLEAN; + +DROP VIEW workspace_build_with_user; + +-- We're adding the has_external_agent column. +CREATE VIEW workspace_build_with_user AS +SELECT + workspace_builds.id, + workspace_builds.created_at, + workspace_builds.updated_at, + workspace_builds.workspace_id, + workspace_builds.template_version_id, + workspace_builds.build_number, + workspace_builds.transition, + workspace_builds.initiator_id, + workspace_builds.provisioner_state, + workspace_builds.job_id, + workspace_builds.deadline, + workspace_builds.reason, + workspace_builds.daily_cost, + workspace_builds.max_deadline, + workspace_builds.template_version_preset_id, + workspace_builds.has_ai_task, + workspace_builds.ai_task_sidebar_app_id, + workspace_builds.has_external_agent, + COALESCE( + visible_users.avatar_url, + '' :: text + ) AS initiator_by_avatar_url, + COALESCE( + visible_users.username, + '' :: text + ) AS initiator_by_username, + COALESCE(visible_users.name, '' :: text) AS initiator_by_name +FROM + ( + workspace_builds + LEFT JOIN visible_users ON ( + ( + workspace_builds.initiator_id = visible_users.id + ) + ) + ); + +COMMENT ON VIEW workspace_build_with_user IS 'Joins in the username + avatar url of the initiated by user.'; diff --git a/coderd/database/modelqueries.go b/coderd/database/modelqueries.go index 8bd57a8fbefc3..96a29f31a2643 100644 --- a/coderd/database/modelqueries.go +++ b/coderd/database/modelqueries.go @@ -82,7 +82,7 @@ func (q *sqlQuerier) GetAuthorizedTemplates(ctx context.Context, arg GetTemplate pq.Array(arg.IDs), arg.Deprecated, arg.HasAITask, - arg.HasExternalAgents, + arg.HasExternalAgent, ) if err != nil { return nil, err @@ -269,6 +269,7 @@ func (q *sqlQuerier) GetAuthorizedWorkspaces(ctx context.Context, arg GetWorkspa arg.LastUsedAfter, arg.UsingActive, arg.HasAITask, + arg.HasExternalAgent, arg.RequesterID, arg.Offset, arg.Limit, @@ -317,6 +318,7 @@ func (q *sqlQuerier) GetAuthorizedWorkspaces(ctx context.Context, arg GetWorkspa &i.LatestBuildTransition, &i.LatestBuildStatus, &i.LatestBuildHasAITask, + &i.LatestBuildHasExternalAgent, &i.Count, ); err != nil { return nil, err diff --git a/coderd/database/models.go b/coderd/database/models.go index 3afe328a23dee..97661a4f3ea32 100644 --- a/coderd/database/models.go +++ b/coderd/database/models.go @@ -3568,7 +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"` + HasExternalAgent sql.NullBool `db:"has_external_agent" json:"has_external_agent"` 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"` @@ -3651,11 +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"` - HasExternalAgents sql.NullBool `db:"has_external_agents" json:"has_external_agents"` + 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"` + HasExternalAgent sql.NullBool `db:"has_external_agent" json:"has_external_agent"` } type TemplateVersionTerraformValue struct { @@ -4074,6 +4074,7 @@ type WorkspaceBuild struct { TemplateVersionPresetID uuid.NullUUID `db:"template_version_preset_id" json:"template_version_preset_id"` HasAITask sql.NullBool `db:"has_ai_task" json:"has_ai_task"` AITaskSidebarAppID uuid.NullUUID `db:"ai_task_sidebar_app_id" json:"ai_task_sidebar_app_id"` + HasExternalAgent sql.NullBool `db:"has_external_agent" json:"has_external_agent"` InitiatorByAvatarUrl string `db:"initiator_by_avatar_url" json:"initiator_by_avatar_url"` InitiatorByUsername string `db:"initiator_by_username" json:"initiator_by_username"` InitiatorByName string `db:"initiator_by_name" json:"initiator_by_name"` @@ -4105,6 +4106,7 @@ type WorkspaceBuildTable struct { TemplateVersionPresetID uuid.NullUUID `db:"template_version_preset_id" json:"template_version_preset_id"` HasAITask sql.NullBool `db:"has_ai_task" json:"has_ai_task"` AITaskSidebarAppID uuid.NullUUID `db:"ai_task_sidebar_app_id" json:"ai_task_sidebar_app_id"` + HasExternalAgent sql.NullBool `db:"has_external_agent" json:"has_external_agent"` } type WorkspaceLatestBuild struct { diff --git a/coderd/database/querier.go b/coderd/database/querier.go index ddade373145fa..82c8dbc6a68ba 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -607,7 +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 + UpdateTemplateVersionExternalAgentByJobID(ctx context.Context, arg UpdateTemplateVersionExternalAgentByJobIDParams) error UpdateTemplateVersionExternalAuthProvidersByJobID(ctx context.Context, arg UpdateTemplateVersionExternalAuthProvidersByJobIDParams) error UpdateTemplateWorkspacesLastUsedAt(ctx context.Context, arg UpdateTemplateWorkspacesLastUsedAtParams) error UpdateUserDeletedByID(ctx context.Context, id uuid.UUID) error @@ -638,6 +638,7 @@ type sqlcQuerier interface { UpdateWorkspaceBuildAITaskByID(ctx context.Context, arg UpdateWorkspaceBuildAITaskByIDParams) error UpdateWorkspaceBuildCostByID(ctx context.Context, arg UpdateWorkspaceBuildCostByIDParams) error UpdateWorkspaceBuildDeadlineByID(ctx context.Context, arg UpdateWorkspaceBuildDeadlineByIDParams) error + UpdateWorkspaceBuildExternalAgentByID(ctx context.Context, arg UpdateWorkspaceBuildExternalAgentByIDParams) error UpdateWorkspaceBuildProvisionerStateByID(ctx context.Context, arg UpdateWorkspaceBuildProvisionerStateByIDParams) error UpdateWorkspaceDeletedByID(ctx context.Context, arg UpdateWorkspaceDeletedByIDParams) error UpdateWorkspaceDormantDeletingAt(ctx context.Context, arg UpdateWorkspaceDormantDeletingAtParams) (WorkspaceTable, error) diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index dae2a8505ac26..6914f5dfc07a0 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -11979,10 +11979,10 @@ WHERE tv.has_ai_task = $7 :: boolean ELSE true END - -- Filter by has_external_agents in latest version + -- Filter by has_external_agent in latest version AND CASE WHEN $8 :: boolean IS NOT NULL THEN - tv.has_external_agents = $8 :: boolean + tv.has_external_agent = $8 :: boolean ELSE true END -- Authorize Filter clause will be injected below in GetAuthorizedTemplates @@ -11991,14 +11991,14 @@ 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"` - HasExternalAgents sql.NullBool `db:"has_external_agents" json:"has_external_agents"` + 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"` + HasExternalAgent sql.NullBool `db:"has_external_agent" json:"has_external_agent"` } func (q *sqlQuerier) GetTemplatesWithFilter(ctx context.Context, arg GetTemplatesWithFilterParams) ([]Template, error) { @@ -12010,7 +12010,7 @@ func (q *sqlQuerier) GetTemplatesWithFilter(ctx context.Context, arg GetTemplate pq.Array(arg.IDs), arg.Deprecated, arg.HasAITask, - arg.HasExternalAgents, + arg.HasExternalAgent, ) if err != nil { return nil, err @@ -12488,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, has_external_agents + 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_agent FROM template_versions WHERE @@ -12589,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, has_external_agents, 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_agent, created_by_avatar_url, created_by_username, created_by_name FROM template_version_with_user AS template_versions WHERE @@ -12628,7 +12628,7 @@ func (q *sqlQuerier) GetPreviousTemplateVersion(ctx context.Context, arg GetPrev &i.Archived, &i.SourceExampleID, &i.HasAITask, - &i.HasExternalAgents, + &i.HasExternalAgent, &i.CreatedByAvatarURL, &i.CreatedByUsername, &i.CreatedByName, @@ -12638,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, has_external_agents, 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_agent, created_by_avatar_url, created_by_username, created_by_name FROM template_version_with_user AS template_versions WHERE @@ -12663,7 +12663,7 @@ func (q *sqlQuerier) GetTemplateVersionByID(ctx context.Context, id uuid.UUID) ( &i.Archived, &i.SourceExampleID, &i.HasAITask, - &i.HasExternalAgents, + &i.HasExternalAgent, &i.CreatedByAvatarURL, &i.CreatedByUsername, &i.CreatedByName, @@ -12673,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, has_external_agents, 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_agent, created_by_avatar_url, created_by_username, created_by_name FROM template_version_with_user AS template_versions WHERE @@ -12698,7 +12698,7 @@ func (q *sqlQuerier) GetTemplateVersionByJobID(ctx context.Context, jobID uuid.U &i.Archived, &i.SourceExampleID, &i.HasAITask, - &i.HasExternalAgents, + &i.HasExternalAgent, &i.CreatedByAvatarURL, &i.CreatedByUsername, &i.CreatedByName, @@ -12708,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, has_external_agents, 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_agent, created_by_avatar_url, created_by_username, created_by_name FROM template_version_with_user AS template_versions WHERE @@ -12739,7 +12739,7 @@ func (q *sqlQuerier) GetTemplateVersionByTemplateIDAndName(ctx context.Context, &i.Archived, &i.SourceExampleID, &i.HasAITask, - &i.HasExternalAgents, + &i.HasExternalAgent, &i.CreatedByAvatarURL, &i.CreatedByUsername, &i.CreatedByName, @@ -12749,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, has_external_agents, 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_agent, created_by_avatar_url, created_by_username, created_by_name FROM template_version_with_user AS template_versions WHERE @@ -12780,7 +12780,7 @@ func (q *sqlQuerier) GetTemplateVersionsByIDs(ctx context.Context, ids []uuid.UU &i.Archived, &i.SourceExampleID, &i.HasAITask, - &i.HasExternalAgents, + &i.HasExternalAgent, &i.CreatedByAvatarURL, &i.CreatedByUsername, &i.CreatedByName, @@ -12800,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, has_external_agents, 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_agent, created_by_avatar_url, created_by_username, created_by_name FROM template_version_with_user AS template_versions WHERE @@ -12878,7 +12878,7 @@ func (q *sqlQuerier) GetTemplateVersionsByTemplateID(ctx context.Context, arg Ge &i.Archived, &i.SourceExampleID, &i.HasAITask, - &i.HasExternalAgents, + &i.HasExternalAgent, &i.CreatedByAvatarURL, &i.CreatedByUsername, &i.CreatedByName, @@ -12897,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, 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 +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_agent, 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) { @@ -12924,7 +12924,7 @@ func (q *sqlQuerier) GetTemplateVersionsCreatedAfter(ctx context.Context, create &i.Archived, &i.SourceExampleID, &i.HasAITask, - &i.HasExternalAgents, + &i.HasExternalAgent, &i.CreatedByAvatarURL, &i.CreatedByUsername, &i.CreatedByName, @@ -13098,24 +13098,24 @@ func (q *sqlQuerier) UpdateTemplateVersionDescriptionByJobID(ctx context.Context return err } -const updateTemplateVersionExternalAgentsByJobID = `-- name: UpdateTemplateVersionExternalAgentsByJobID :exec +const updateTemplateVersionExternalAgentByJobID = `-- name: UpdateTemplateVersionExternalAgentByJobID :exec UPDATE template_versions SET - has_external_agents = $2, + has_external_agent = $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"` +type UpdateTemplateVersionExternalAgentByJobIDParams struct { + JobID uuid.UUID `db:"job_id" json:"job_id"` + HasExternalAgent sql.NullBool `db:"has_external_agent" json:"has_external_agent"` + 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) +func (q *sqlQuerier) UpdateTemplateVersionExternalAgentByJobID(ctx context.Context, arg UpdateTemplateVersionExternalAgentByJobIDParams) error { + _, err := q.db.ExecContext(ctx, updateTemplateVersionExternalAgentByJobID, arg.JobID, arg.HasExternalAgent, arg.UpdatedAt) return err } @@ -15392,7 +15392,7 @@ const getWorkspaceAgentAndLatestBuildByAuthToken = `-- name: GetWorkspaceAgentAn SELECT workspaces.id, workspaces.created_at, workspaces.updated_at, workspaces.owner_id, workspaces.organization_id, workspaces.template_id, workspaces.deleted, workspaces.name, workspaces.autostart_schedule, workspaces.ttl, workspaces.last_used_at, workspaces.dormant_at, workspaces.deleting_at, workspaces.automatic_updates, workspaces.favorite, workspaces.next_start_at, workspace_agents.id, workspace_agents.created_at, workspace_agents.updated_at, workspace_agents.name, workspace_agents.first_connected_at, workspace_agents.last_connected_at, workspace_agents.disconnected_at, workspace_agents.resource_id, workspace_agents.auth_token, workspace_agents.auth_instance_id, workspace_agents.architecture, workspace_agents.environment_variables, workspace_agents.operating_system, workspace_agents.instance_metadata, workspace_agents.resource_metadata, workspace_agents.directory, workspace_agents.version, workspace_agents.last_connected_replica_id, workspace_agents.connection_timeout_seconds, workspace_agents.troubleshooting_url, workspace_agents.motd_file, workspace_agents.lifecycle_state, workspace_agents.expanded_directory, workspace_agents.logs_length, workspace_agents.logs_overflowed, workspace_agents.started_at, workspace_agents.ready_at, workspace_agents.subsystems, workspace_agents.display_apps, workspace_agents.api_version, workspace_agents.display_order, workspace_agents.parent_id, workspace_agents.api_key_scope, workspace_agents.deleted, - workspace_build_with_user.id, workspace_build_with_user.created_at, workspace_build_with_user.updated_at, workspace_build_with_user.workspace_id, workspace_build_with_user.template_version_id, workspace_build_with_user.build_number, workspace_build_with_user.transition, workspace_build_with_user.initiator_id, workspace_build_with_user.provisioner_state, workspace_build_with_user.job_id, workspace_build_with_user.deadline, workspace_build_with_user.reason, workspace_build_with_user.daily_cost, workspace_build_with_user.max_deadline, workspace_build_with_user.template_version_preset_id, workspace_build_with_user.has_ai_task, workspace_build_with_user.ai_task_sidebar_app_id, workspace_build_with_user.initiator_by_avatar_url, workspace_build_with_user.initiator_by_username, workspace_build_with_user.initiator_by_name + workspace_build_with_user.id, workspace_build_with_user.created_at, workspace_build_with_user.updated_at, workspace_build_with_user.workspace_id, workspace_build_with_user.template_version_id, workspace_build_with_user.build_number, workspace_build_with_user.transition, workspace_build_with_user.initiator_id, workspace_build_with_user.provisioner_state, workspace_build_with_user.job_id, workspace_build_with_user.deadline, workspace_build_with_user.reason, workspace_build_with_user.daily_cost, workspace_build_with_user.max_deadline, workspace_build_with_user.template_version_preset_id, workspace_build_with_user.has_ai_task, workspace_build_with_user.ai_task_sidebar_app_id, workspace_build_with_user.has_external_agent, workspace_build_with_user.initiator_by_avatar_url, workspace_build_with_user.initiator_by_username, workspace_build_with_user.initiator_by_name FROM workspace_agents JOIN @@ -15503,6 +15503,7 @@ func (q *sqlQuerier) GetWorkspaceAgentAndLatestBuildByAuthToken(ctx context.Cont &i.WorkspaceBuild.TemplateVersionPresetID, &i.WorkspaceBuild.HasAITask, &i.WorkspaceBuild.AITaskSidebarAppID, + &i.WorkspaceBuild.HasExternalAgent, &i.WorkspaceBuild.InitiatorByAvatarUrl, &i.WorkspaceBuild.InitiatorByUsername, &i.WorkspaceBuild.InitiatorByName, @@ -18157,7 +18158,7 @@ func (q *sqlQuerier) InsertWorkspaceBuildParameters(ctx context.Context, arg Ins } const getActiveWorkspaceBuildsByTemplateID = `-- name: GetActiveWorkspaceBuildsByTemplateID :many -SELECT wb.id, wb.created_at, wb.updated_at, wb.workspace_id, wb.template_version_id, wb.build_number, wb.transition, wb.initiator_id, wb.provisioner_state, wb.job_id, wb.deadline, wb.reason, wb.daily_cost, wb.max_deadline, wb.template_version_preset_id, wb.has_ai_task, wb.ai_task_sidebar_app_id, wb.initiator_by_avatar_url, wb.initiator_by_username, wb.initiator_by_name +SELECT wb.id, wb.created_at, wb.updated_at, wb.workspace_id, wb.template_version_id, wb.build_number, wb.transition, wb.initiator_id, wb.provisioner_state, wb.job_id, wb.deadline, wb.reason, wb.daily_cost, wb.max_deadline, wb.template_version_preset_id, wb.has_ai_task, wb.ai_task_sidebar_app_id, wb.has_external_agent, wb.initiator_by_avatar_url, wb.initiator_by_username, wb.initiator_by_name FROM ( SELECT workspace_id, MAX(build_number) as max_build_number @@ -18214,6 +18215,7 @@ func (q *sqlQuerier) GetActiveWorkspaceBuildsByTemplateID(ctx context.Context, t &i.TemplateVersionPresetID, &i.HasAITask, &i.AITaskSidebarAppID, + &i.HasExternalAgent, &i.InitiatorByAvatarUrl, &i.InitiatorByUsername, &i.InitiatorByName, @@ -18313,7 +18315,7 @@ func (q *sqlQuerier) GetFailedWorkspaceBuildsByTemplateID(ctx context.Context, a const getLatestWorkspaceBuildByWorkspaceID = `-- name: GetLatestWorkspaceBuildByWorkspaceID :one SELECT - id, created_at, updated_at, workspace_id, template_version_id, build_number, transition, initiator_id, provisioner_state, job_id, deadline, reason, daily_cost, max_deadline, template_version_preset_id, has_ai_task, ai_task_sidebar_app_id, initiator_by_avatar_url, initiator_by_username, initiator_by_name + id, created_at, updated_at, workspace_id, template_version_id, build_number, transition, initiator_id, provisioner_state, job_id, deadline, reason, daily_cost, max_deadline, template_version_preset_id, has_ai_task, ai_task_sidebar_app_id, has_external_agent, initiator_by_avatar_url, initiator_by_username, initiator_by_name FROM workspace_build_with_user AS workspace_builds WHERE @@ -18345,6 +18347,7 @@ func (q *sqlQuerier) GetLatestWorkspaceBuildByWorkspaceID(ctx context.Context, w &i.TemplateVersionPresetID, &i.HasAITask, &i.AITaskSidebarAppID, + &i.HasExternalAgent, &i.InitiatorByAvatarUrl, &i.InitiatorByUsername, &i.InitiatorByName, @@ -18353,7 +18356,7 @@ func (q *sqlQuerier) GetLatestWorkspaceBuildByWorkspaceID(ctx context.Context, w } const getLatestWorkspaceBuilds = `-- name: GetLatestWorkspaceBuilds :many -SELECT wb.id, wb.created_at, wb.updated_at, wb.workspace_id, wb.template_version_id, wb.build_number, wb.transition, wb.initiator_id, wb.provisioner_state, wb.job_id, wb.deadline, wb.reason, wb.daily_cost, wb.max_deadline, wb.template_version_preset_id, wb.has_ai_task, wb.ai_task_sidebar_app_id, wb.initiator_by_avatar_url, wb.initiator_by_username, wb.initiator_by_name +SELECT wb.id, wb.created_at, wb.updated_at, wb.workspace_id, wb.template_version_id, wb.build_number, wb.transition, wb.initiator_id, wb.provisioner_state, wb.job_id, wb.deadline, wb.reason, wb.daily_cost, wb.max_deadline, wb.template_version_preset_id, wb.has_ai_task, wb.ai_task_sidebar_app_id, wb.has_external_agent, wb.initiator_by_avatar_url, wb.initiator_by_username, wb.initiator_by_name FROM ( SELECT workspace_id, MAX(build_number) as max_build_number @@ -18394,6 +18397,7 @@ func (q *sqlQuerier) GetLatestWorkspaceBuilds(ctx context.Context) ([]WorkspaceB &i.TemplateVersionPresetID, &i.HasAITask, &i.AITaskSidebarAppID, + &i.HasExternalAgent, &i.InitiatorByAvatarUrl, &i.InitiatorByUsername, &i.InitiatorByName, @@ -18412,7 +18416,7 @@ func (q *sqlQuerier) GetLatestWorkspaceBuilds(ctx context.Context) ([]WorkspaceB } const getLatestWorkspaceBuildsByWorkspaceIDs = `-- name: GetLatestWorkspaceBuildsByWorkspaceIDs :many -SELECT wb.id, wb.created_at, wb.updated_at, wb.workspace_id, wb.template_version_id, wb.build_number, wb.transition, wb.initiator_id, wb.provisioner_state, wb.job_id, wb.deadline, wb.reason, wb.daily_cost, wb.max_deadline, wb.template_version_preset_id, wb.has_ai_task, wb.ai_task_sidebar_app_id, wb.initiator_by_avatar_url, wb.initiator_by_username, wb.initiator_by_name +SELECT wb.id, wb.created_at, wb.updated_at, wb.workspace_id, wb.template_version_id, wb.build_number, wb.transition, wb.initiator_id, wb.provisioner_state, wb.job_id, wb.deadline, wb.reason, wb.daily_cost, wb.max_deadline, wb.template_version_preset_id, wb.has_ai_task, wb.ai_task_sidebar_app_id, wb.has_external_agent, wb.initiator_by_avatar_url, wb.initiator_by_username, wb.initiator_by_name FROM ( SELECT workspace_id, MAX(build_number) as max_build_number @@ -18455,6 +18459,7 @@ func (q *sqlQuerier) GetLatestWorkspaceBuildsByWorkspaceIDs(ctx context.Context, &i.TemplateVersionPresetID, &i.HasAITask, &i.AITaskSidebarAppID, + &i.HasExternalAgent, &i.InitiatorByAvatarUrl, &i.InitiatorByUsername, &i.InitiatorByName, @@ -18474,7 +18479,7 @@ func (q *sqlQuerier) GetLatestWorkspaceBuildsByWorkspaceIDs(ctx context.Context, const getWorkspaceBuildByID = `-- name: GetWorkspaceBuildByID :one SELECT - id, created_at, updated_at, workspace_id, template_version_id, build_number, transition, initiator_id, provisioner_state, job_id, deadline, reason, daily_cost, max_deadline, template_version_preset_id, has_ai_task, ai_task_sidebar_app_id, initiator_by_avatar_url, initiator_by_username, initiator_by_name + id, created_at, updated_at, workspace_id, template_version_id, build_number, transition, initiator_id, provisioner_state, job_id, deadline, reason, daily_cost, max_deadline, template_version_preset_id, has_ai_task, ai_task_sidebar_app_id, has_external_agent, initiator_by_avatar_url, initiator_by_username, initiator_by_name FROM workspace_build_with_user AS workspace_builds WHERE @@ -18504,6 +18509,7 @@ func (q *sqlQuerier) GetWorkspaceBuildByID(ctx context.Context, id uuid.UUID) (W &i.TemplateVersionPresetID, &i.HasAITask, &i.AITaskSidebarAppID, + &i.HasExternalAgent, &i.InitiatorByAvatarUrl, &i.InitiatorByUsername, &i.InitiatorByName, @@ -18513,7 +18519,7 @@ func (q *sqlQuerier) GetWorkspaceBuildByID(ctx context.Context, id uuid.UUID) (W const getWorkspaceBuildByJobID = `-- name: GetWorkspaceBuildByJobID :one SELECT - id, created_at, updated_at, workspace_id, template_version_id, build_number, transition, initiator_id, provisioner_state, job_id, deadline, reason, daily_cost, max_deadline, template_version_preset_id, has_ai_task, ai_task_sidebar_app_id, initiator_by_avatar_url, initiator_by_username, initiator_by_name + id, created_at, updated_at, workspace_id, template_version_id, build_number, transition, initiator_id, provisioner_state, job_id, deadline, reason, daily_cost, max_deadline, template_version_preset_id, has_ai_task, ai_task_sidebar_app_id, has_external_agent, initiator_by_avatar_url, initiator_by_username, initiator_by_name FROM workspace_build_with_user AS workspace_builds WHERE @@ -18543,6 +18549,7 @@ func (q *sqlQuerier) GetWorkspaceBuildByJobID(ctx context.Context, jobID uuid.UU &i.TemplateVersionPresetID, &i.HasAITask, &i.AITaskSidebarAppID, + &i.HasExternalAgent, &i.InitiatorByAvatarUrl, &i.InitiatorByUsername, &i.InitiatorByName, @@ -18552,7 +18559,7 @@ func (q *sqlQuerier) GetWorkspaceBuildByJobID(ctx context.Context, jobID uuid.UU const getWorkspaceBuildByWorkspaceIDAndBuildNumber = `-- name: GetWorkspaceBuildByWorkspaceIDAndBuildNumber :one SELECT - id, created_at, updated_at, workspace_id, template_version_id, build_number, transition, initiator_id, provisioner_state, job_id, deadline, reason, daily_cost, max_deadline, template_version_preset_id, has_ai_task, ai_task_sidebar_app_id, initiator_by_avatar_url, initiator_by_username, initiator_by_name + id, created_at, updated_at, workspace_id, template_version_id, build_number, transition, initiator_id, provisioner_state, job_id, deadline, reason, daily_cost, max_deadline, template_version_preset_id, has_ai_task, ai_task_sidebar_app_id, has_external_agent, initiator_by_avatar_url, initiator_by_username, initiator_by_name FROM workspace_build_with_user AS workspace_builds WHERE @@ -18586,6 +18593,7 @@ func (q *sqlQuerier) GetWorkspaceBuildByWorkspaceIDAndBuildNumber(ctx context.Co &i.TemplateVersionPresetID, &i.HasAITask, &i.AITaskSidebarAppID, + &i.HasExternalAgent, &i.InitiatorByAvatarUrl, &i.InitiatorByUsername, &i.InitiatorByName, @@ -18662,7 +18670,7 @@ func (q *sqlQuerier) GetWorkspaceBuildStatsByTemplates(ctx context.Context, sinc const getWorkspaceBuildsByWorkspaceID = `-- name: GetWorkspaceBuildsByWorkspaceID :many SELECT - id, created_at, updated_at, workspace_id, template_version_id, build_number, transition, initiator_id, provisioner_state, job_id, deadline, reason, daily_cost, max_deadline, template_version_preset_id, has_ai_task, ai_task_sidebar_app_id, initiator_by_avatar_url, initiator_by_username, initiator_by_name + id, created_at, updated_at, workspace_id, template_version_id, build_number, transition, initiator_id, provisioner_state, job_id, deadline, reason, daily_cost, max_deadline, template_version_preset_id, has_ai_task, ai_task_sidebar_app_id, has_external_agent, initiator_by_avatar_url, initiator_by_username, initiator_by_name FROM workspace_build_with_user AS workspace_builds WHERE @@ -18735,6 +18743,7 @@ func (q *sqlQuerier) GetWorkspaceBuildsByWorkspaceID(ctx context.Context, arg Ge &i.TemplateVersionPresetID, &i.HasAITask, &i.AITaskSidebarAppID, + &i.HasExternalAgent, &i.InitiatorByAvatarUrl, &i.InitiatorByUsername, &i.InitiatorByName, @@ -18753,7 +18762,7 @@ func (q *sqlQuerier) GetWorkspaceBuildsByWorkspaceID(ctx context.Context, arg Ge } const getWorkspaceBuildsCreatedAfter = `-- name: GetWorkspaceBuildsCreatedAfter :many -SELECT id, created_at, updated_at, workspace_id, template_version_id, build_number, transition, initiator_id, provisioner_state, job_id, deadline, reason, daily_cost, max_deadline, template_version_preset_id, has_ai_task, ai_task_sidebar_app_id, initiator_by_avatar_url, initiator_by_username, initiator_by_name FROM workspace_build_with_user WHERE created_at > $1 +SELECT id, created_at, updated_at, workspace_id, template_version_id, build_number, transition, initiator_id, provisioner_state, job_id, deadline, reason, daily_cost, max_deadline, template_version_preset_id, has_ai_task, ai_task_sidebar_app_id, has_external_agent, initiator_by_avatar_url, initiator_by_username, initiator_by_name FROM workspace_build_with_user WHERE created_at > $1 ` func (q *sqlQuerier) GetWorkspaceBuildsCreatedAfter(ctx context.Context, createdAt time.Time) ([]WorkspaceBuild, error) { @@ -18783,6 +18792,7 @@ func (q *sqlQuerier) GetWorkspaceBuildsCreatedAfter(ctx context.Context, created &i.TemplateVersionPresetID, &i.HasAITask, &i.AITaskSidebarAppID, + &i.HasExternalAgent, &i.InitiatorByAvatarUrl, &i.InitiatorByUsername, &i.InitiatorByName, @@ -18932,6 +18942,26 @@ func (q *sqlQuerier) UpdateWorkspaceBuildDeadlineByID(ctx context.Context, arg U return err } +const updateWorkspaceBuildExternalAgentByID = `-- name: UpdateWorkspaceBuildExternalAgentByID :exec +UPDATE + workspace_builds +SET + has_external_agent = $1, + updated_at = $2::timestamptz +WHERE id = $3::uuid +` + +type UpdateWorkspaceBuildExternalAgentByIDParams struct { + HasExternalAgent sql.NullBool `db:"has_external_agent" json:"has_external_agent"` + UpdatedAt time.Time `db:"updated_at" json:"updated_at"` + ID uuid.UUID `db:"id" json:"id"` +} + +func (q *sqlQuerier) UpdateWorkspaceBuildExternalAgentByID(ctx context.Context, arg UpdateWorkspaceBuildExternalAgentByIDParams) error { + _, err := q.db.ExecContext(ctx, updateWorkspaceBuildExternalAgentByID, arg.HasExternalAgent, arg.UpdatedAt, arg.ID) + return err +} + const updateWorkspaceBuildProvisionerStateByID = `-- name: UpdateWorkspaceBuildProvisionerStateByID :exec UPDATE workspace_builds @@ -19889,7 +19919,8 @@ SELECT latest_build.error as latest_build_error, latest_build.transition as latest_build_transition, latest_build.job_status as latest_build_status, - latest_build.has_ai_task as latest_build_has_ai_task + latest_build.has_ai_task as latest_build_has_ai_task, + latest_build.has_external_agent as latest_build_has_external_agent FROM workspaces_expanded as workspaces JOIN @@ -19902,6 +19933,7 @@ LEFT JOIN LATERAL ( workspace_builds.transition, workspace_builds.template_version_id, workspace_builds.has_ai_task, + workspace_builds.has_external_agent, template_versions.name AS template_version_name, provisioner_jobs.id AS provisioner_job_id, provisioner_jobs.started_at, @@ -20142,16 +20174,22 @@ WHERE )) = ($19 :: boolean) ELSE true END + -- Filter by has_external_agent in latest build + AND CASE + WHEN $20 :: boolean IS NOT NULL THEN + latest_build.has_external_agent = $20 :: boolean + ELSE true + END -- Authorize Filter clause will be injected below in GetAuthorizedWorkspaces -- @authorize_filter ), filtered_workspaces_order AS ( SELECT - fw.id, fw.created_at, fw.updated_at, fw.owner_id, fw.organization_id, fw.template_id, fw.deleted, fw.name, fw.autostart_schedule, fw.ttl, fw.last_used_at, fw.dormant_at, fw.deleting_at, fw.automatic_updates, fw.favorite, fw.next_start_at, fw.owner_avatar_url, fw.owner_username, fw.owner_name, fw.organization_name, fw.organization_display_name, fw.organization_icon, fw.organization_description, fw.template_name, fw.template_display_name, fw.template_icon, fw.template_description, fw.template_version_id, fw.template_version_name, fw.latest_build_completed_at, fw.latest_build_canceled_at, fw.latest_build_error, fw.latest_build_transition, fw.latest_build_status, fw.latest_build_has_ai_task + fw.id, fw.created_at, fw.updated_at, fw.owner_id, fw.organization_id, fw.template_id, fw.deleted, fw.name, fw.autostart_schedule, fw.ttl, fw.last_used_at, fw.dormant_at, fw.deleting_at, fw.automatic_updates, fw.favorite, fw.next_start_at, fw.owner_avatar_url, fw.owner_username, fw.owner_name, fw.organization_name, fw.organization_display_name, fw.organization_icon, fw.organization_description, fw.template_name, fw.template_display_name, fw.template_icon, fw.template_description, fw.template_version_id, fw.template_version_name, fw.latest_build_completed_at, fw.latest_build_canceled_at, fw.latest_build_error, fw.latest_build_transition, fw.latest_build_status, fw.latest_build_has_ai_task, fw.latest_build_has_external_agent FROM filtered_workspaces fw ORDER BY -- To ensure that 'favorite' workspaces show up first in the list only for their owner. - CASE WHEN owner_id = $20 AND favorite THEN 0 ELSE 1 END ASC, + CASE WHEN owner_id = $21 AND favorite THEN 0 ELSE 1 END ASC, (latest_build_completed_at IS NOT NULL AND latest_build_canceled_at IS NULL AND latest_build_error IS NULL AND @@ -20160,14 +20198,14 @@ WHERE LOWER(name) ASC LIMIT CASE - WHEN $22 :: integer > 0 THEN - $22 + WHEN $23 :: integer > 0 THEN + $23 END OFFSET - $21 + $22 ), filtered_workspaces_order_with_summary AS ( SELECT - fwo.id, fwo.created_at, fwo.updated_at, fwo.owner_id, fwo.organization_id, fwo.template_id, fwo.deleted, fwo.name, fwo.autostart_schedule, fwo.ttl, fwo.last_used_at, fwo.dormant_at, fwo.deleting_at, fwo.automatic_updates, fwo.favorite, fwo.next_start_at, fwo.owner_avatar_url, fwo.owner_username, fwo.owner_name, fwo.organization_name, fwo.organization_display_name, fwo.organization_icon, fwo.organization_description, fwo.template_name, fwo.template_display_name, fwo.template_icon, fwo.template_description, fwo.template_version_id, fwo.template_version_name, fwo.latest_build_completed_at, fwo.latest_build_canceled_at, fwo.latest_build_error, fwo.latest_build_transition, fwo.latest_build_status, fwo.latest_build_has_ai_task + fwo.id, fwo.created_at, fwo.updated_at, fwo.owner_id, fwo.organization_id, fwo.template_id, fwo.deleted, fwo.name, fwo.autostart_schedule, fwo.ttl, fwo.last_used_at, fwo.dormant_at, fwo.deleting_at, fwo.automatic_updates, fwo.favorite, fwo.next_start_at, fwo.owner_avatar_url, fwo.owner_username, fwo.owner_name, fwo.organization_name, fwo.organization_display_name, fwo.organization_icon, fwo.organization_description, fwo.template_name, fwo.template_display_name, fwo.template_icon, fwo.template_description, fwo.template_version_id, fwo.template_version_name, fwo.latest_build_completed_at, fwo.latest_build_canceled_at, fwo.latest_build_error, fwo.latest_build_transition, fwo.latest_build_status, fwo.latest_build_has_ai_task, fwo.latest_build_has_external_agent FROM filtered_workspaces_order fwo -- Return a technical summary row with total count of workspaces. @@ -20209,9 +20247,10 @@ WHERE '', -- latest_build_error 'start'::workspace_transition, -- latest_build_transition 'unknown'::provisioner_job_status, -- latest_build_status - false -- latest_build_has_ai_task + false, -- latest_build_has_ai_task + false -- latest_build_has_external_agent WHERE - $23 :: boolean = true + $24 :: boolean = true ), total_count AS ( SELECT count(*) AS count @@ -20219,7 +20258,7 @@ WHERE filtered_workspaces ) SELECT - fwos.id, fwos.created_at, fwos.updated_at, fwos.owner_id, fwos.organization_id, fwos.template_id, fwos.deleted, fwos.name, fwos.autostart_schedule, fwos.ttl, fwos.last_used_at, fwos.dormant_at, fwos.deleting_at, fwos.automatic_updates, fwos.favorite, fwos.next_start_at, fwos.owner_avatar_url, fwos.owner_username, fwos.owner_name, fwos.organization_name, fwos.organization_display_name, fwos.organization_icon, fwos.organization_description, fwos.template_name, fwos.template_display_name, fwos.template_icon, fwos.template_description, fwos.template_version_id, fwos.template_version_name, fwos.latest_build_completed_at, fwos.latest_build_canceled_at, fwos.latest_build_error, fwos.latest_build_transition, fwos.latest_build_status, fwos.latest_build_has_ai_task, + fwos.id, fwos.created_at, fwos.updated_at, fwos.owner_id, fwos.organization_id, fwos.template_id, fwos.deleted, fwos.name, fwos.autostart_schedule, fwos.ttl, fwos.last_used_at, fwos.dormant_at, fwos.deleting_at, fwos.automatic_updates, fwos.favorite, fwos.next_start_at, fwos.owner_avatar_url, fwos.owner_username, fwos.owner_name, fwos.organization_name, fwos.organization_display_name, fwos.organization_icon, fwos.organization_description, fwos.template_name, fwos.template_display_name, fwos.template_icon, fwos.template_description, fwos.template_version_id, fwos.template_version_name, fwos.latest_build_completed_at, fwos.latest_build_canceled_at, fwos.latest_build_error, fwos.latest_build_transition, fwos.latest_build_status, fwos.latest_build_has_ai_task, fwos.latest_build_has_external_agent, tc.count FROM filtered_workspaces_order_with_summary fwos @@ -20247,6 +20286,7 @@ type GetWorkspacesParams struct { LastUsedAfter time.Time `db:"last_used_after" json:"last_used_after"` UsingActive sql.NullBool `db:"using_active" json:"using_active"` HasAITask sql.NullBool `db:"has_ai_task" json:"has_ai_task"` + HasExternalAgent sql.NullBool `db:"has_external_agent" json:"has_external_agent"` RequesterID uuid.UUID `db:"requester_id" json:"requester_id"` Offset int32 `db:"offset_" json:"offset_"` Limit int32 `db:"limit_" json:"limit_"` @@ -20254,42 +20294,43 @@ type GetWorkspacesParams struct { } type GetWorkspacesRow struct { - ID uuid.UUID `db:"id" json:"id"` - CreatedAt time.Time `db:"created_at" json:"created_at"` - UpdatedAt time.Time `db:"updated_at" json:"updated_at"` - OwnerID uuid.UUID `db:"owner_id" json:"owner_id"` - OrganizationID uuid.UUID `db:"organization_id" json:"organization_id"` - TemplateID uuid.UUID `db:"template_id" json:"template_id"` - Deleted bool `db:"deleted" json:"deleted"` - Name string `db:"name" json:"name"` - AutostartSchedule sql.NullString `db:"autostart_schedule" json:"autostart_schedule"` - Ttl sql.NullInt64 `db:"ttl" json:"ttl"` - LastUsedAt time.Time `db:"last_used_at" json:"last_used_at"` - DormantAt sql.NullTime `db:"dormant_at" json:"dormant_at"` - DeletingAt sql.NullTime `db:"deleting_at" json:"deleting_at"` - AutomaticUpdates AutomaticUpdates `db:"automatic_updates" json:"automatic_updates"` - Favorite bool `db:"favorite" json:"favorite"` - NextStartAt sql.NullTime `db:"next_start_at" json:"next_start_at"` - OwnerAvatarUrl string `db:"owner_avatar_url" json:"owner_avatar_url"` - OwnerUsername string `db:"owner_username" json:"owner_username"` - OwnerName string `db:"owner_name" json:"owner_name"` - OrganizationName string `db:"organization_name" json:"organization_name"` - OrganizationDisplayName string `db:"organization_display_name" json:"organization_display_name"` - OrganizationIcon string `db:"organization_icon" json:"organization_icon"` - OrganizationDescription string `db:"organization_description" json:"organization_description"` - TemplateName string `db:"template_name" json:"template_name"` - TemplateDisplayName string `db:"template_display_name" json:"template_display_name"` - TemplateIcon string `db:"template_icon" json:"template_icon"` - TemplateDescription string `db:"template_description" json:"template_description"` - TemplateVersionID uuid.UUID `db:"template_version_id" json:"template_version_id"` - TemplateVersionName sql.NullString `db:"template_version_name" json:"template_version_name"` - LatestBuildCompletedAt sql.NullTime `db:"latest_build_completed_at" json:"latest_build_completed_at"` - LatestBuildCanceledAt sql.NullTime `db:"latest_build_canceled_at" json:"latest_build_canceled_at"` - LatestBuildError sql.NullString `db:"latest_build_error" json:"latest_build_error"` - LatestBuildTransition WorkspaceTransition `db:"latest_build_transition" json:"latest_build_transition"` - LatestBuildStatus ProvisionerJobStatus `db:"latest_build_status" json:"latest_build_status"` - LatestBuildHasAITask sql.NullBool `db:"latest_build_has_ai_task" json:"latest_build_has_ai_task"` - Count int64 `db:"count" json:"count"` + ID uuid.UUID `db:"id" json:"id"` + CreatedAt time.Time `db:"created_at" json:"created_at"` + UpdatedAt time.Time `db:"updated_at" json:"updated_at"` + OwnerID uuid.UUID `db:"owner_id" json:"owner_id"` + OrganizationID uuid.UUID `db:"organization_id" json:"organization_id"` + TemplateID uuid.UUID `db:"template_id" json:"template_id"` + Deleted bool `db:"deleted" json:"deleted"` + Name string `db:"name" json:"name"` + AutostartSchedule sql.NullString `db:"autostart_schedule" json:"autostart_schedule"` + Ttl sql.NullInt64 `db:"ttl" json:"ttl"` + LastUsedAt time.Time `db:"last_used_at" json:"last_used_at"` + DormantAt sql.NullTime `db:"dormant_at" json:"dormant_at"` + DeletingAt sql.NullTime `db:"deleting_at" json:"deleting_at"` + AutomaticUpdates AutomaticUpdates `db:"automatic_updates" json:"automatic_updates"` + Favorite bool `db:"favorite" json:"favorite"` + NextStartAt sql.NullTime `db:"next_start_at" json:"next_start_at"` + OwnerAvatarUrl string `db:"owner_avatar_url" json:"owner_avatar_url"` + OwnerUsername string `db:"owner_username" json:"owner_username"` + OwnerName string `db:"owner_name" json:"owner_name"` + OrganizationName string `db:"organization_name" json:"organization_name"` + OrganizationDisplayName string `db:"organization_display_name" json:"organization_display_name"` + OrganizationIcon string `db:"organization_icon" json:"organization_icon"` + OrganizationDescription string `db:"organization_description" json:"organization_description"` + TemplateName string `db:"template_name" json:"template_name"` + TemplateDisplayName string `db:"template_display_name" json:"template_display_name"` + TemplateIcon string `db:"template_icon" json:"template_icon"` + TemplateDescription string `db:"template_description" json:"template_description"` + TemplateVersionID uuid.UUID `db:"template_version_id" json:"template_version_id"` + TemplateVersionName sql.NullString `db:"template_version_name" json:"template_version_name"` + LatestBuildCompletedAt sql.NullTime `db:"latest_build_completed_at" json:"latest_build_completed_at"` + LatestBuildCanceledAt sql.NullTime `db:"latest_build_canceled_at" json:"latest_build_canceled_at"` + LatestBuildError sql.NullString `db:"latest_build_error" json:"latest_build_error"` + LatestBuildTransition WorkspaceTransition `db:"latest_build_transition" json:"latest_build_transition"` + LatestBuildStatus ProvisionerJobStatus `db:"latest_build_status" json:"latest_build_status"` + LatestBuildHasAITask sql.NullBool `db:"latest_build_has_ai_task" json:"latest_build_has_ai_task"` + LatestBuildHasExternalAgent sql.NullBool `db:"latest_build_has_external_agent" json:"latest_build_has_external_agent"` + Count int64 `db:"count" json:"count"` } // build_params is used to filter by build parameters if present. @@ -20316,6 +20357,7 @@ func (q *sqlQuerier) GetWorkspaces(ctx context.Context, arg GetWorkspacesParams) arg.LastUsedAfter, arg.UsingActive, arg.HasAITask, + arg.HasExternalAgent, arg.RequesterID, arg.Offset, arg.Limit, @@ -20364,6 +20406,7 @@ func (q *sqlQuerier) GetWorkspaces(ctx context.Context, arg GetWorkspacesParams) &i.LatestBuildTransition, &i.LatestBuildStatus, &i.LatestBuildHasAITask, + &i.LatestBuildHasExternalAgent, &i.Count, ); err != nil { return nil, err diff --git a/coderd/database/queries/templates.sql b/coderd/database/queries/templates.sql index 7f1f507d472d7..a8d15c2b4a9f3 100644 --- a/coderd/database/queries/templates.sql +++ b/coderd/database/queries/templates.sql @@ -59,10 +59,10 @@ WHERE tv.has_ai_task = sqlc.narg('has_ai_task') :: boolean ELSE true END - -- Filter by has_external_agents in latest version + -- Filter by has_external_agent 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 + WHEN sqlc.narg('has_external_agent') :: boolean IS NOT NULL THEN + tv.has_external_agent = sqlc.narg('has_external_agent') :: boolean ELSE true END -- Authorize Filter clause will be injected below in GetAuthorizedTemplates diff --git a/coderd/database/queries/templateversions.sql b/coderd/database/queries/templateversions.sql index 58f6614b7d72b..0e93b0f726b16 100644 --- a/coderd/database/queries/templateversions.sql +++ b/coderd/database/queries/templateversions.sql @@ -239,11 +239,11 @@ RETURNING template_versions.id; -- 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 +-- name: UpdateTemplateVersionExternalAgentByJobID :exec UPDATE template_versions SET - has_external_agents = $2, + has_external_agent = $2, updated_at = $3 WHERE job_id = $1; diff --git a/coderd/database/queries/workspacebuilds.sql b/coderd/database/queries/workspacebuilds.sql index be76b6642df1f..c4929f1c04871 100644 --- a/coderd/database/queries/workspacebuilds.sql +++ b/coderd/database/queries/workspacebuilds.sql @@ -253,3 +253,11 @@ WHERE AND pj.job_status = 'failed' ORDER BY tv.name ASC, wb.build_number DESC; + +-- name: UpdateWorkspaceBuildExternalAgentByID :exec +UPDATE + workspace_builds +SET + has_external_agent = @has_external_agent, + updated_at = @updated_at::timestamptz +WHERE id = @id::uuid; diff --git a/coderd/database/queries/workspaces.sql b/coderd/database/queries/workspaces.sql index f166d16f742cd..ba102934b6dec 100644 --- a/coderd/database/queries/workspaces.sql +++ b/coderd/database/queries/workspaces.sql @@ -117,7 +117,8 @@ SELECT latest_build.error as latest_build_error, latest_build.transition as latest_build_transition, latest_build.job_status as latest_build_status, - latest_build.has_ai_task as latest_build_has_ai_task + latest_build.has_ai_task as latest_build_has_ai_task, + latest_build.has_external_agent as latest_build_has_external_agent FROM workspaces_expanded as workspaces JOIN @@ -130,6 +131,7 @@ LEFT JOIN LATERAL ( workspace_builds.transition, workspace_builds.template_version_id, workspace_builds.has_ai_task, + workspace_builds.has_external_agent, template_versions.name AS template_version_name, provisioner_jobs.id AS provisioner_job_id, provisioner_jobs.started_at, @@ -370,6 +372,12 @@ WHERE )) = (sqlc.narg('has_ai_task') :: boolean) ELSE true END + -- Filter by has_external_agent in latest build + AND CASE + WHEN sqlc.narg('has_external_agent') :: boolean IS NOT NULL THEN + latest_build.has_external_agent = sqlc.narg('has_external_agent') :: boolean + ELSE true + END -- Authorize Filter clause will be injected below in GetAuthorizedWorkspaces -- @authorize_filter ), filtered_workspaces_order AS ( @@ -437,7 +445,8 @@ WHERE '', -- latest_build_error 'start'::workspace_transition, -- latest_build_transition 'unknown'::provisioner_job_status, -- latest_build_status - false -- latest_build_has_ai_task + false, -- latest_build_has_ai_task + false -- latest_build_has_external_agent WHERE @with_summary :: boolean = true ), total_count AS ( diff --git a/coderd/provisionerdserver/provisionerdserver.go b/coderd/provisionerdserver/provisionerdserver.go index 8121e48289e40..80a56f30138b1 100644 --- a/coderd/provisionerdserver/provisionerdserver.go +++ b/coderd/provisionerdserver/provisionerdserver.go @@ -1666,9 +1666,9 @@ 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{ + err = db.UpdateTemplateVersionExternalAgentByJobID(ctx, database.UpdateTemplateVersionExternalAgentByJobIDParams{ JobID: jobID, - HasExternalAgents: sql.NullBool{ + HasExternalAgent: sql.NullBool{ Bool: jobType.TemplateImport.HasExternalAgents, Valid: true, }, @@ -1919,6 +1919,26 @@ func (s *server) completeWorkspaceBuildJob(ctx context.Context, job database.Pro return xerrors.Errorf("update workspace build ai tasks flag: %w", err) } + // Check if there is a coder_external_agent resource in the workspace build + hasExternalAgent := false + for _, resource := range jobType.WorkspaceBuild.Resources { + if resource.Type == "coder_external_agent" { + hasExternalAgent = true + break + } + } + err = db.UpdateWorkspaceBuildExternalAgentByID(ctx, database.UpdateWorkspaceBuildExternalAgentByIDParams{ + ID: workspaceBuild.ID, + HasExternalAgent: sql.NullBool{ + Bool: hasExternalAgent, + Valid: true, + }, + UpdatedAt: now, + }) + if err != nil { + return xerrors.Errorf("update workspace build external agent flag: %w", err) + } + // Insert timings inside the transaction now // nolint:exhaustruct // The other fields are set further down. params := database.InsertProvisionerJobTimingsParams{ diff --git a/coderd/searchquery/search.go b/coderd/searchquery/search.go index 80f995b8ff248..9a020dec87700 100644 --- a/coderd/searchquery/search.go +++ b/coderd/searchquery/search.go @@ -223,6 +223,7 @@ func Workspaces(ctx context.Context, db database.Store, query string, page coder Valid: values.Has("outdated"), } filter.HasAITask = parser.NullableBoolean(values, sql.NullBool{}, "has-ai-task") + filter.HasExternalAgent = parser.NullableBoolean(values, sql.NullBool{}, "has-external-agent") filter.OrganizationID = parseOrganization(ctx, db, parser, values, "organization") type paramMatch struct { @@ -277,14 +278,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"), - HasExternalAgents: parser.NullableBoolean(values, sql.NullBool{}, "has-external-agents"), + 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"), + HasExternalAgent: parser.NullableBoolean(values, sql.NullBool{}, "has-external-agent"), } parser.ErrorExcessParams(values) diff --git a/coderd/workspaces.go b/coderd/workspaces.go index 0f3f0a24c75d3..b5950b5a1dc50 100644 --- a/coderd/workspaces.go +++ b/coderd/workspaces.go @@ -137,7 +137,7 @@ func (api *API) workspace(rw http.ResponseWriter, r *http.Request) { // @Security CoderSessionToken // @Produce json // @Tags Workspaces -// @Param q query string false "Search query in the format `key:value`. Available keys are: owner, template, name, status, has-agent, dormant, last_used_after, last_used_before, has-ai-task." +// @Param q query string false "Search query in the format `key:value`. Available keys are: owner, template, name, status, has-agent, dormant, last_used_after, last_used_before, has-ai-task, has-external-agent." // @Param limit query int false "Page limit" // @Param offset query int false "Page offset" // @Success 200 {object} codersdk.WorkspacesResponse diff --git a/codersdk/workspacebuilds.go b/codersdk/workspacebuilds.go index 53d2a89290bca..bb9511178c7f4 100644 --- a/codersdk/workspacebuilds.go +++ b/codersdk/workspacebuilds.go @@ -90,6 +90,7 @@ type WorkspaceBuild struct { TemplateVersionPresetID *uuid.UUID `json:"template_version_preset_id" format:"uuid"` HasAITask *bool `json:"has_ai_task,omitempty"` AITaskSidebarAppID *uuid.UUID `json:"ai_task_sidebar_app_id,omitempty" format:"uuid"` + HasExternalAgent *bool `json:"has_external_agent,omitempty"` } // WorkspaceResource describes resources used to create a workspace, for instance: diff --git a/docs/admin/security/audit-logs.md b/docs/admin/security/audit-logs.md index 2a294dfe34ca1..f59f4e593dbb6 100644 --- a/docs/admin/security/audit-logs.md +++ b/docs/admin/security/audit-logs.md @@ -33,9 +33,9 @@ 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
has_external_agentsfalse
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_agentfalse
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
| +| WorkspaceBuild
start, stop | |
FieldTracked
ai_task_sidebar_app_idfalse
build_numberfalse
created_atfalse
daily_costfalse
deadlinefalse
has_ai_taskfalse
has_external_agentfalse
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
| | WorkspaceTable
| |
FieldTracked
automatic_updatestrue
autostart_scheduletrue
created_atfalse
deletedfalse
deleting_attrue
dormant_attrue
favoritetrue
idtrue
last_used_atfalse
nametrue
next_start_attrue
organization_idfalse
owner_idtrue
template_idtrue
ttltrue
updated_atfalse
| diff --git a/docs/manifest.json b/docs/manifest.json index 0625819213e68..d49b18463e864 100644 --- a/docs/manifest.json +++ b/docs/manifest.json @@ -1111,11 +1111,6 @@ "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/builds.md b/docs/reference/api/builds.md index fb491405df362..23bd13b7d38a5 100644 --- a/docs/reference/api/builds.md +++ b/docs/reference/api/builds.md @@ -33,6 +33,7 @@ curl -X GET http://coder-server:8080/api/v2/users/{user}/workspace/{workspacenam "daily_cost": 0, "deadline": "2019-08-24T14:15:22Z", "has_ai_task": true, + "has_external_agent": true, "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "initiator_id": "06588898-9a84-4b35-ba8f-f9cbd64946f3", "initiator_name": "string", @@ -270,6 +271,7 @@ curl -X GET http://coder-server:8080/api/v2/workspacebuilds/{workspacebuild} \ "daily_cost": 0, "deadline": "2019-08-24T14:15:22Z", "has_ai_task": true, + "has_external_agent": true, "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "initiator_id": "06588898-9a84-4b35-ba8f-f9cbd64946f3", "initiator_name": "string", @@ -996,6 +998,7 @@ curl -X GET http://coder-server:8080/api/v2/workspacebuilds/{workspacebuild}/sta "daily_cost": 0, "deadline": "2019-08-24T14:15:22Z", "has_ai_task": true, + "has_external_agent": true, "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "initiator_id": "06588898-9a84-4b35-ba8f-f9cbd64946f3", "initiator_name": "string", @@ -1306,6 +1309,7 @@ curl -X GET http://coder-server:8080/api/v2/workspaces/{workspace}/builds \ "daily_cost": 0, "deadline": "2019-08-24T14:15:22Z", "has_ai_task": true, + "has_external_agent": true, "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "initiator_id": "06588898-9a84-4b35-ba8f-f9cbd64946f3", "initiator_name": "string", @@ -1524,6 +1528,7 @@ Status Code **200** | `» daily_cost` | integer | false | | | | `» deadline` | string(date-time) | false | | | | `» has_ai_task` | boolean | false | | | +| `» has_external_agent` | boolean | false | | | | `» id` | string(uuid) | false | | | | `» initiator_id` | string(uuid) | false | | | | `» initiator_name` | string | false | | | @@ -1797,6 +1802,7 @@ curl -X POST http://coder-server:8080/api/v2/workspaces/{workspace}/builds \ "daily_cost": 0, "deadline": "2019-08-24T14:15:22Z", "has_ai_task": true, + "has_external_agent": true, "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "initiator_id": "06588898-9a84-4b35-ba8f-f9cbd64946f3", "initiator_name": "string", diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index 227422483d4f0..4f39ba80edd90 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -8772,6 +8772,7 @@ If the schedule is empty, the user will be updated to use the default schedule.| "daily_cost": 0, "deadline": "2019-08-24T14:15:22Z", "has_ai_task": true, + "has_external_agent": true, "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "initiator_id": "06588898-9a84-4b35-ba8f-f9cbd64946f3", "initiator_name": "string", @@ -9881,6 +9882,7 @@ If the schedule is empty, the user will be updated to use the default schedule.| "daily_cost": 0, "deadline": "2019-08-24T14:15:22Z", "has_ai_task": true, + "has_external_agent": true, "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "initiator_id": "06588898-9a84-4b35-ba8f-f9cbd64946f3", "initiator_name": "string", @@ -10089,6 +10091,7 @@ If the schedule is empty, the user will be updated to use the default schedule.| | `daily_cost` | integer | false | | | | `deadline` | string | false | | | | `has_ai_task` | boolean | false | | | +| `has_external_agent` | boolean | false | | | | `id` | string | false | | | | `initiator_id` | string | false | | | | `initiator_name` | string | false | | | @@ -10612,6 +10615,7 @@ If the schedule is empty, the user will be updated to use the default schedule.| "daily_cost": 0, "deadline": "2019-08-24T14:15:22Z", "has_ai_task": true, + "has_external_agent": true, "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "initiator_id": "06588898-9a84-4b35-ba8f-f9cbd64946f3", "initiator_name": "string", diff --git a/docs/reference/api/workspaces.md b/docs/reference/api/workspaces.md index debcb421e02e3..27b17a2a95468 100644 --- a/docs/reference/api/workspaces.md +++ b/docs/reference/api/workspaces.md @@ -88,6 +88,7 @@ of the template will be used. "daily_cost": 0, "deadline": "2019-08-24T14:15:22Z", "has_ai_task": true, + "has_external_agent": true, "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "initiator_id": "06588898-9a84-4b35-ba8f-f9cbd64946f3", "initiator_name": "string", @@ -375,6 +376,7 @@ curl -X GET http://coder-server:8080/api/v2/users/{user}/workspace/{workspacenam "daily_cost": 0, "deadline": "2019-08-24T14:15:22Z", "has_ai_task": true, + "has_external_agent": true, "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "initiator_id": "06588898-9a84-4b35-ba8f-f9cbd64946f3", "initiator_name": "string", @@ -687,6 +689,7 @@ of the template will be used. "daily_cost": 0, "deadline": "2019-08-24T14:15:22Z", "has_ai_task": true, + "has_external_agent": true, "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "initiator_id": "06588898-9a84-4b35-ba8f-f9cbd64946f3", "initiator_name": "string", @@ -927,11 +930,11 @@ curl -X GET http://coder-server:8080/api/v2/workspaces \ ### Parameters -| Name | In | Type | Required | Description | -|----------|-------|---------|----------|----------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `q` | query | string | false | Search query in the format `key:value`. Available keys are: owner, template, name, status, has-agent, dormant, last_used_after, last_used_before, has-ai-task. | -| `limit` | query | integer | false | Page limit | -| `offset` | query | integer | false | Page offset | +| Name | In | Type | Required | Description | +|----------|-------|---------|----------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `q` | query | string | false | Search query in the format `key:value`. Available keys are: owner, template, name, status, has-agent, dormant, last_used_after, last_used_before, has-ai-task, has-external-agent. | +| `limit` | query | integer | false | Page limit | +| `offset` | query | integer | false | Page offset | ### Example responses @@ -977,6 +980,7 @@ curl -X GET http://coder-server:8080/api/v2/workspaces \ "daily_cost": 0, "deadline": "2019-08-24T14:15:22Z", "has_ai_task": true, + "has_external_agent": true, "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "initiator_id": "06588898-9a84-4b35-ba8f-f9cbd64946f3", "initiator_name": "string", @@ -1248,6 +1252,7 @@ curl -X GET http://coder-server:8080/api/v2/workspaces/{workspace} \ "daily_cost": 0, "deadline": "2019-08-24T14:15:22Z", "has_ai_task": true, + "has_external_agent": true, "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "initiator_id": "06588898-9a84-4b35-ba8f-f9cbd64946f3", "initiator_name": "string", @@ -1651,6 +1656,7 @@ curl -X PUT http://coder-server:8080/api/v2/workspaces/{workspace}/dormant \ "daily_cost": 0, "deadline": "2019-08-24T14:15:22Z", "has_ai_task": true, + "has_external_agent": true, "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "initiator_id": "06588898-9a84-4b35-ba8f-f9cbd64946f3", "initiator_name": "string", diff --git a/docs/reference/cli/attach.md b/docs/reference/cli/attach.md deleted file mode 100644 index e2ca483a03f4f..0000000000000 --- a/docs/reference/cli/attach.md +++ /dev/null @@ -1,82 +0,0 @@ - -# 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 094eb01fe05e8..3a90838c11309 100644 --- a/docs/reference/cli/index.md +++ b/docs/reference/cli/index.md @@ -40,7 +40,6 @@ 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/enterprise/audit/table.go b/enterprise/audit/table.go index 72f3d72f27993..cbda3cbc1f42e 100644 --- a/enterprise/audit/table.go +++ b/enterprise/audit/table.go @@ -134,7 +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. + "has_external_agent": ActionIgnore, // Never changes. }, &database.User{}: { "id": ActionTrack, @@ -195,6 +195,7 @@ var auditableResourcesTypes = map[any]map[string]Action{ "template_version_preset_id": ActionIgnore, // Never changes. "has_ai_task": ActionIgnore, // Never changes. "ai_task_sidebar_app_id": ActionIgnore, // Never changes. + "has_external_agent": ActionIgnore, // Never changes. }, &database.AuditableGroup{}: { "id": ActionTrack, diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 0bf716efe80b4..53273244378ad 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -3852,6 +3852,7 @@ export interface WorkspaceBuild { readonly template_version_preset_id: string | null; readonly has_ai_task?: boolean; readonly ai_task_sidebar_app_id?: string; + readonly has_external_agent?: boolean; } // From codersdk/workspacebuilds.go From fd2458bcc25448d42a77be31f0cc3c856c85289b Mon Sep 17 00:00:00 2001 From: Kacper Sawicki Date: Wed, 30 Jul 2025 14:27:02 +0000 Subject: [PATCH 05/28] add list command for external workspaces --- cli/external_workspaces.go | 82 +++++++++++++++++-- .../coder_external-workspaces_--help.golden | 1 + ...der_external-workspaces_list_--help.golden | 24 ++++++ cli/testdata/coder_list_--output_json.golden | 3 +- coderd/workspacebuilds.go | 6 ++ docs/manifest.json | 5 ++ docs/reference/cli/external-workspaces.md | 1 + .../reference/cli/external-workspaces_list.md | 51 ++++++++++++ 8 files changed, 164 insertions(+), 9 deletions(-) create mode 100644 cli/testdata/coder_external-workspaces_list_--help.golden create mode 100644 docs/reference/cli/external-workspaces_list.md diff --git a/cli/external_workspaces.go b/cli/external_workspaces.go index 2e8ffcd1b87a4..ce9f15243095f 100644 --- a/cli/external_workspaces.go +++ b/cli/external_workspaces.go @@ -33,6 +33,7 @@ func (r *RootCmd) externalWorkspaces() *serpent.Command { Children: []*serpent.Command{ r.externalWorkspaceCreate(), r.externalWorkspaceAgentInstructions(), + r.externalWorkspaceList(), }, } @@ -138,9 +139,9 @@ func (r *RootCmd) externalWorkspaceAgentInstructions() *serpent.Command { } 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))) + _, _ = 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 }), @@ -204,6 +205,71 @@ func (r *RootCmd) externalWorkspaceAgentInstructions() *serpent.Command { return cmd } +func (r *RootCmd) externalWorkspaceList() *serpent.Command { + var ( + filter cliui.WorkspaceFilter + formatter = cliui.NewOutputFormatter( + cliui.TableFormat( + []workspaceListRow{}, + []string{ + "workspace", + "template", + "status", + "healthy", + "last built", + "current version", + "outdated", + }, + ), + cliui.JSONFormat(), + ) + ) + client := new(codersdk.Client) + cmd := &serpent.Command{ + Annotations: workspaceCommand, + Use: "list", + Short: "List external workspaces", + Aliases: []string{"ls"}, + Middleware: serpent.Chain( + serpent.RequireNArgs(0), + r.InitClient(client), + ), + Handler: func(inv *serpent.Invocation) error { + baseFilter := filter.Filter() + + if baseFilter.FilterQuery == "" { + baseFilter.FilterQuery = "has-external-agent:true" + } else { + baseFilter.FilterQuery += " has-external-agent:true" + } + + res, err := queryConvertWorkspaces(inv.Context(), client, baseFilter, workspaceListRowFromWorkspace) + if err != nil { + return err + } + + if len(res) == 0 && formatter.FormatID() != cliui.JSONFormat().ID() { + pretty.Fprintf(inv.Stderr, cliui.DefaultStyles.Prompt, "No workspaces found! Create one:\n") + _, _ = fmt.Fprintln(inv.Stderr) + _, _ = fmt.Fprintln(inv.Stderr, " "+pretty.Sprint(cliui.DefaultStyles.Code, "coder external-workspaces create ")) + _, _ = fmt.Fprintln(inv.Stderr) + return nil + } + + out, err := formatter.Format(inv.Context(), res) + if err != nil { + return err + } + + _, err = fmt.Fprintln(inv.Stdout, out) + return err + }, + } + filter.AttachOptions(&cmd.Options) + formatter.AttachOptions(&cmd.Options) + return cmd +} + // fetchExternalAgents fetches the external agents for a workspace. func fetchExternalAgents(inv *serpent.Invocation, client *codersdk.Client, workspace codersdk.Workspace, resources []codersdk.WorkspaceResource) ([]externalAgent, error) { if len(resources) == 0 { @@ -241,18 +307,18 @@ func fetchExternalAgents(inv *serpent.Invocation, client *codersdk.Client, works // 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)) + _, _ = 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, "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))) + _, _ = 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") + _, _ = fmt.Fprintf(inv.Stdout, "\n") } } diff --git a/cli/testdata/coder_external-workspaces_--help.golden b/cli/testdata/coder_external-workspaces_--help.golden index 8d3eed2f3b00b..2d7aec4eef7db 100644 --- a/cli/testdata/coder_external-workspaces_--help.golden +++ b/cli/testdata/coder_external-workspaces_--help.golden @@ -8,6 +8,7 @@ USAGE: SUBCOMMANDS: agent-instructions Get the instructions for an external agent create Create a new external workspace + list List external workspaces OPTIONS: -O, --org string, $CODER_ORGANIZATION diff --git a/cli/testdata/coder_external-workspaces_list_--help.golden b/cli/testdata/coder_external-workspaces_list_--help.golden new file mode 100644 index 0000000000000..1210bea5aa186 --- /dev/null +++ b/cli/testdata/coder_external-workspaces_list_--help.golden @@ -0,0 +1,24 @@ +coder v0.0.0-devel + +USAGE: + coder external-workspaces list [flags] + + List external workspaces + + Aliases: ls + +OPTIONS: + -a, --all bool + Specifies whether all workspaces will be listed or not. + + -c, --column [favorite|workspace|organization id|organization name|template|status|healthy|last built|current version|outdated|starts at|starts next|stops after|stops next|daily cost] (default: workspace,template,status,healthy,last built,current version,outdated) + Columns to display in table output. + + -o, --output table|json (default: table) + Output format. + + --search string (default: owner:me) + Search for a workspace with a query. + +——— +Run `coder --help` for a list of global options. diff --git a/cli/testdata/coder_list_--output_json.golden b/cli/testdata/coder_list_--output_json.golden index 51c2887cd1e4a..1ea31af6c8118 100644 --- a/cli/testdata/coder_list_--output_json.golden +++ b/cli/testdata/coder_list_--output_json.golden @@ -69,7 +69,8 @@ "most_recently_seen": null }, "template_version_preset_id": null, - "has_ai_task": false + "has_ai_task": false, + "has_external_agent": false }, "latest_app_status": null, "outdated": false, diff --git a/coderd/workspacebuilds.go b/coderd/workspacebuilds.go index 583b9c4edaf21..e54f75ef5cba6 100644 --- a/coderd/workspacebuilds.go +++ b/coderd/workspacebuilds.go @@ -1157,6 +1157,11 @@ func (api *API) convertWorkspaceBuild( aiTasksSidebarAppID = &build.AITaskSidebarAppID.UUID } + var hasExternalAgent *bool + if build.HasExternalAgent.Valid { + hasExternalAgent = &build.HasExternalAgent.Bool + } + apiJob := convertProvisionerJob(job) transition := codersdk.WorkspaceTransition(build.Transition) return codersdk.WorkspaceBuild{ @@ -1185,6 +1190,7 @@ func (api *API) convertWorkspaceBuild( TemplateVersionPresetID: presetID, HasAITask: hasAITask, AITaskSidebarAppID: aiTasksSidebarAppID, + HasExternalAgent: hasExternalAgent, }, nil } diff --git a/docs/manifest.json b/docs/manifest.json index d49b18463e864..33e0d0875ce66 100644 --- a/docs/manifest.json +++ b/docs/manifest.json @@ -1170,6 +1170,11 @@ "description": "Create a new external workspace", "path": "reference/cli/external-workspaces_create.md" }, + { + "title": "external-workspaces list", + "description": "List external workspaces", + "path": "reference/cli/external-workspaces_list.md" + }, { "title": "favorite", "description": "Add a workspace to your favorites", diff --git a/docs/reference/cli/external-workspaces.md b/docs/reference/cli/external-workspaces.md index ccc1546e1bd00..b36e5352b9013 100644 --- a/docs/reference/cli/external-workspaces.md +++ b/docs/reference/cli/external-workspaces.md @@ -15,6 +15,7 @@ coder external-workspaces [flags] [subcommand] |--------------------------------------------------------------------------------|--------------------------------------------| | [create](./external-workspaces_create.md) | Create a new external workspace | | [agent-instructions](./external-workspaces_agent-instructions.md) | Get the instructions for an external agent | +| [list](./external-workspaces_list.md) | List external workspaces | ## Options diff --git a/docs/reference/cli/external-workspaces_list.md b/docs/reference/cli/external-workspaces_list.md new file mode 100644 index 0000000000000..061aaa29d7a0b --- /dev/null +++ b/docs/reference/cli/external-workspaces_list.md @@ -0,0 +1,51 @@ + +# external-workspaces list + +List external workspaces + +Aliases: + +* ls + +## Usage + +```console +coder external-workspaces list [flags] +``` + +## Options + +### -a, --all + +| | | +|------|-------------------| +| Type | bool | + +Specifies whether all workspaces will be listed or not. + +### --search + +| | | +|---------|-----------------------| +| Type | string | +| Default | owner:me | + +Search for a workspace with a query. + +### -c, --column + +| | | +|---------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| Type | [favorite\|workspace\|organization id\|organization name\|template\|status\|healthy\|last built\|current version\|outdated\|starts at\|starts next\|stops after\|stops next\|daily cost] | +| Default | workspace,template,status,healthy,last built,current version,outdated | + +Columns to display in table output. + +### -o, --output + +| | | +|---------|--------------------------| +| Type | table\|json | +| Default | table | + +Output format. From 0c39f5060d27b45479f204635646d54e43e0aa3b Mon Sep 17 00:00:00 2001 From: Kacper Sawicki Date: Wed, 30 Jul 2025 14:30:32 +0000 Subject: [PATCH 06/28] add AgentExternal component to display external agent connection details, extend CodeExample to redact parts of the code --- site/src/api/api.ts | 10 +++ .../CodeExample/CodeExample.stories.tsx | 9 +++ .../components/CodeExample/CodeExample.tsx | 57 +++++++++++++- .../resources/AgentExternal.stories.tsx | 77 +++++++++++++++++++ site/src/modules/resources/AgentExternal.tsx | 56 ++++++++++++++ site/src/modules/resources/AgentRow.tsx | 11 ++- site/src/modules/workspaces/actions.ts | 8 ++ 7 files changed, 222 insertions(+), 6 deletions(-) create mode 100644 site/src/modules/resources/AgentExternal.stories.tsx create mode 100644 site/src/modules/resources/AgentExternal.tsx diff --git a/site/src/api/api.ts b/site/src/api/api.ts index 9a46c40217091..d38d2112c7286 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -2008,6 +2008,16 @@ class ApiMethods { return response.data; }; + getWorkspaceAgentCredentials = async ( + workspaceID: string, + agentName: string, + ): Promise => { + const response = await this.axios.get( + `/api/v2/workspaces/${workspaceID}/external-agent/${agentName}/credentials`, + ); + return response.data; + }; + upsertWorkspaceAgentSharedPort = async ( workspaceID: string, req: TypesGen.UpsertWorkspaceAgentPortShareRequest, diff --git a/site/src/components/CodeExample/CodeExample.stories.tsx b/site/src/components/CodeExample/CodeExample.stories.tsx index 93283e4df74a3..067c8c9791567 100644 --- a/site/src/components/CodeExample/CodeExample.stories.tsx +++ b/site/src/components/CodeExample/CodeExample.stories.tsx @@ -31,3 +31,12 @@ export const LongCode: Story = { code: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAICnKzATuWwmmt5+CKTPuRGN0R1PBemA+6/SStpLiyX+L", }, }; + +export const Redact: Story = { + args: { + secret: false, + redactPattern: /CODER_AGENT_TOKEN="([^"]+)"/g, + redactReplacement: `CODER_AGENT_TOKEN="********"`, + redactShowButton: true, + }, +}; diff --git a/site/src/components/CodeExample/CodeExample.tsx b/site/src/components/CodeExample/CodeExample.tsx index 474dcb1fac225..b3b193b158c81 100644 --- a/site/src/components/CodeExample/CodeExample.tsx +++ b/site/src/components/CodeExample/CodeExample.tsx @@ -1,11 +1,17 @@ import type { Interpolation, Theme } from "@emotion/react"; -import type { FC } from "react"; +import { useState, type FC } from "react"; import { MONOSPACE_FONT_FAMILY } from "theme/constants"; import { CopyButton } from "../CopyButton/CopyButton"; +import { TooltipContent, TooltipTrigger, TooltipProvider, Tooltip } from "components/Tooltip/Tooltip"; +import { Button } from "components/Button/Button"; +import { EyeIcon, EyeOffIcon } from "lucide-react"; interface CodeExampleProps { code: string; secret?: boolean; + redactPattern?: RegExp; + redactReplacement?: string; + redactShowButton?: boolean; className?: string; } @@ -19,7 +25,25 @@ export const CodeExample: FC = ({ // Defaulting to true to be on the safe side; you should have to opt out of // the secure option, not remember to opt in secret = true, + + // Redact parts of the code if the user doesn't want to obfuscate the whole code + redactPattern, + redactReplacement = "********", + + // Show a button to show the redacted parts of the code + redactShowButton = false, }) => { + const [showFullValue, setShowFullValue] = useState(false); + + const displayValue = secret + ? obfuscateText(code) + : redactPattern && !showFullValue + ? code.replace(redactPattern, redactReplacement) + : code; + + const showButtonLabel = showFullValue ? "Hide sensitive data" : "Show sensitive data"; + const icon = showFullValue ? : ; + return (
@@ -33,17 +57,36 @@ export const CodeExample: FC = ({ * 2. Even with it turned on and supported, the plaintext is still * readily available in the HTML itself */} - {obfuscateText(code)} + {displayValue} Encrypted text. Please access via the copy button. ) : ( - code + displayValue )} - +
+ {redactShowButton && redactPattern && !secret && ( + + + + + + {showButtonLabel} + + + )} + +
); }; @@ -80,4 +123,10 @@ const styles = { secret: { "-webkit-text-security": "disc", // also supported by firefox }, + + actions: { + display: "flex", + alignItems: "center", + gap: 4, + }, } satisfies Record>; diff --git a/site/src/modules/resources/AgentExternal.stories.tsx b/site/src/modules/resources/AgentExternal.stories.tsx new file mode 100644 index 0000000000000..70772344e8761 --- /dev/null +++ b/site/src/modules/resources/AgentExternal.stories.tsx @@ -0,0 +1,77 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { chromatic } from "testHelpers/chromatic"; +import { + MockWorkspace, + MockWorkspaceAgent, +} from "testHelpers/entities"; +import { + withDashboardProvider, +} from "testHelpers/storybook"; +import { AgentExternal } from "./AgentExternal"; + +const meta: Meta = { + title: "modules/resources/AgentExternal", + component: AgentExternal, + args: { + isExternalAgent: true, + agent: { + ...MockWorkspaceAgent, + status: "connecting", + operating_system: "linux", + architecture: "amd64", + }, + workspace: MockWorkspace, + }, + decorators: [withDashboardProvider], + parameters: { + chromatic, + }, +}; + +export default meta; +type Story = StoryObj; + +export const Connecting: Story = { + args: { + agent: { + ...MockWorkspaceAgent, + status: "connecting", + operating_system: "linux", + architecture: "amd64", + }, + }, +}; + +export const Timeout: Story = { + args: { + agent: { + ...MockWorkspaceAgent, + status: "timeout", + operating_system: "linux", + architecture: "amd64", + }, + }, +}; + +export const DifferentOS: Story = { + args: { + agent: { + ...MockWorkspaceAgent, + status: "connecting", + operating_system: "darwin", + architecture: "arm64", + }, + }, +}; + +export const NotExternalAgent: Story = { + args: { + isExternalAgent: false, + agent: { + ...MockWorkspaceAgent, + status: "connecting", + operating_system: "linux", + architecture: "amd64", + }, + }, +}; diff --git a/site/src/modules/resources/AgentExternal.tsx b/site/src/modules/resources/AgentExternal.tsx new file mode 100644 index 0000000000000..e5229edb9e73d --- /dev/null +++ b/site/src/modules/resources/AgentExternal.tsx @@ -0,0 +1,56 @@ +import type { Interpolation, Theme } from "@emotion/react"; +import { API } from "api/api"; +import type { Workspace, WorkspaceAgent } from "api/typesGenerated"; +import isChromatic from "chromatic/isChromatic"; +import { CodeExample } from "components/CodeExample/CodeExample"; +import { useEffect, useState, type FC } from "react"; + +interface AgentExternalProps { + isExternalAgent: boolean; + agent: WorkspaceAgent; + workspace: Workspace; +} + +export const AgentExternal: FC = ({ + isExternalAgent, + agent, + workspace, +}) => { + const [externalAgentToken, setExternalAgentToken] = useState(null); + + const origin = isChromatic() ? "https://example.com" : window.location.origin; + let initScriptURL = `${origin}/api/v2/init-script`; + if (agent.operating_system !== "linux" || agent.architecture !== "amd64") { + initScriptURL = `${initScriptURL}?os=${agent.operating_system}&arch=${agent.architecture}`; + } + + useEffect(() => { + if (isExternalAgent && (agent.status === "timeout" || agent.status === "connecting")) { + API.getWorkspaceAgentCredentials(workspace.id, agent.name).then((res) => { + setExternalAgentToken(res.agent_token); + }); + } + }, [isExternalAgent, agent.status, workspace.id, agent.name]); + + return
+

+ Please run the following command to attach an agent to the {workspace.name} workspace: +

+ +
; +}; + +const styles = { + externalAgentSection: (theme) => ({ + fontSize: 16, + color: theme.palette.text.secondary, + paddingBottom: 8, + lineHeight: 1.4, + }), +} satisfies Record>; diff --git a/site/src/modules/resources/AgentRow.tsx b/site/src/modules/resources/AgentRow.tsx index 20c551fc73065..fb8601697e0e9 100644 --- a/site/src/modules/resources/AgentRow.tsx +++ b/site/src/modules/resources/AgentRow.tsx @@ -40,6 +40,7 @@ import { TerminalLink } from "./TerminalLink/TerminalLink"; import { VSCodeDesktopButton } from "./VSCodeDesktopButton/VSCodeDesktopButton"; import { useAgentContainers } from "./useAgentContainers"; import { useAgentLogs } from "./useAgentLogs"; +import { AgentExternal } from "./AgentExternal"; interface AgentRowProps { agent: WorkspaceAgent; @@ -62,6 +63,7 @@ export const AgentRow: FC = ({ const appSections = organizeAgentApps(agent.apps); const hasAppsToDisplay = !browser_only || appSections.some((it) => it.apps.length > 0); + const isExternalAgent = workspace.latest_build.has_external_agent; const shouldDisplayAgentApps = (agent.status === "connected" && hasAppsToDisplay) || agent.status === "connecting"; @@ -74,7 +76,7 @@ export const AgentRow: FC = ({ const { proxy } = useProxy(); const [showLogs, setShowLogs] = useState( ["starting", "start_timeout"].includes(agent.lifecycle_state) && - hasStartupFeatures, + hasStartupFeatures, ); const agentLogs = useAgentLogs(agent, showLogs); const logListRef = useRef(null); @@ -258,7 +260,7 @@ export const AgentRow: FC = ({ )} - {agent.status === "connecting" && ( + {agent.status === "connecting" && !isExternalAgent && (
= ({
)} + + {isExternalAgent && (agent.status === "timeout" || agent.status === "connecting") && ( + + )} + diff --git a/site/src/modules/workspaces/actions.ts b/site/src/modules/workspaces/actions.ts index 8b17d3e937c74..533cf981ed6d8 100644 --- a/site/src/modules/workspaces/actions.ts +++ b/site/src/modules/workspaces/actions.ts @@ -63,6 +63,14 @@ export const abilitiesByWorkspaceStatus = ( }; } + if (workspace.latest_build.has_external_agent) { + return { + actions: [], + canCancel: false, + canAcceptJobs: true, + }; + } + const status = workspace.latest_build.status; switch (status) { From f9f5be1891c21621d36b4e2ce555858f0515f3bc Mon Sep 17 00:00:00 2001 From: Kacper Sawicki Date: Thu, 31 Jul 2025 14:18:07 +0000 Subject: [PATCH 07/28] add tests --- cli/exp_scaletest.go | 2 +- cli/external_workspaces_test.go | 472 ++++++++++++++++++ cli/start.go | 2 +- ...r_external-workspaces_create_--help.golden | 4 + coderd/database/dbgen/dbgen.go | 18 + ...wn.sql => 000356_external_agents.down.sql} | 0 ...s.up.sql => 000356_external_agents.up.sql} | 0 coderd/database/queries.sql.go | 225 ++++++--- coderd/searchquery/search_test.go | 60 +++ coderd/templates_test.go | 56 +++ docs/admin/security/audit-logs.md | 4 +- .../cli/external-workspaces_create.md | 9 + provisioner/terraform/provision_test.go | 26 + provisioner/terraform/resources_test.go | 29 ++ .../external-agents.tfplan.dot | 22 + .../external-agents.tfplan.json | 277 ++++++++++ .../external-agents.tfstate.dot | 22 + .../external-agents.tfstate.json | 138 +++++ .../resources/external-agents/main.tf | 21 + .../terraform/testdata/resources/version.txt | 2 +- provisionersdk/proto/provisioner.pb.go | 19 +- 21 files changed, 1326 insertions(+), 82 deletions(-) create mode 100644 cli/external_workspaces_test.go rename coderd/database/migrations/{000351_external_agents.down.sql => 000356_external_agents.down.sql} (100%) rename coderd/database/migrations/{000351_external_agents.up.sql => 000356_external_agents.up.sql} (100%) create mode 100644 provisioner/terraform/testdata/resources/external-agents/external-agents.tfplan.dot create mode 100644 provisioner/terraform/testdata/resources/external-agents/external-agents.tfplan.json create mode 100644 provisioner/terraform/testdata/resources/external-agents/external-agents.tfstate.dot create mode 100644 provisioner/terraform/testdata/resources/external-agents/external-agents.tfstate.json create mode 100644 provisioner/terraform/testdata/resources/external-agents/main.tf diff --git a/cli/exp_scaletest.go b/cli/exp_scaletest.go index 628605f09c1e5..a844a7e8c6258 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/external_workspaces_test.go b/cli/external_workspaces_test.go new file mode 100644 index 0000000000000..f437f8b2281ab --- /dev/null +++ b/cli/external_workspaces_test.go @@ -0,0 +1,472 @@ +package cli_test + +import ( + "bytes" + "context" + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/coder/coder/v2/cli/clitest" + "github.com/coder/coder/v2/coderd/coderdtest" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/provisioner/echo" + "github.com/coder/coder/v2/provisionersdk/proto" + "github.com/coder/coder/v2/pty/ptytest" + "github.com/coder/coder/v2/testutil" +) + +// completeWithExternalAgent creates a template version with an external agent resource +func completeWithExternalAgent() *echo.Responses { + return &echo.Responses{ + Parse: echo.ParseComplete, + ProvisionPlan: []*proto.Response{ + { + Type: &proto.Response_Plan{ + Plan: &proto.PlanComplete{ + Resources: []*proto.Resource{ + { + Type: "coder_external_agent", + Name: "main", + Agents: []*proto.Agent{ + { + Name: "external-agent", + OperatingSystem: "linux", + Architecture: "amd64", + }, + }, + }, + }, + }, + }, + }, + }, + ProvisionApply: []*proto.Response{ + { + Type: &proto.Response_Apply{ + Apply: &proto.ApplyComplete{ + Resources: []*proto.Resource{ + { + Type: "coder_external_agent", + Name: "main", + Agents: []*proto.Agent{ + { + Name: "external-agent", + OperatingSystem: "linux", + Architecture: "amd64", + }, + }, + }, + }, + }, + }, + }, + }, + } +} + +// completeWithRegularAgent creates a template version with a regular agent (no external agent) +func completeWithRegularAgent() *echo.Responses { + return &echo.Responses{ + Parse: echo.ParseComplete, + ProvisionPlan: []*proto.Response{ + { + Type: &proto.Response_Plan{ + Plan: &proto.PlanComplete{ + Resources: []*proto.Resource{ + { + Type: "compute", + Name: "main", + Agents: []*proto.Agent{ + { + Name: "regular-agent", + OperatingSystem: "linux", + Architecture: "amd64", + }, + }, + }, + }, + }, + }, + }, + }, + ProvisionApply: []*proto.Response{ + { + Type: &proto.Response_Apply{ + Apply: &proto.ApplyComplete{ + Resources: []*proto.Resource{ + { + Type: "compute", + Name: "main", + Agents: []*proto.Agent{ + { + Name: "regular-agent", + OperatingSystem: "linux", + Architecture: "amd64", + }, + }, + }, + }, + }, + }, + }, + }, + } +} + +func TestExternalWorkspaces(t *testing.T) { + t.Parallel() + + t.Run("Create", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) + owner := coderdtest.CreateFirstUser(t, client) + member, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID) + version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, completeWithExternalAgent()) + coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) + template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID) + + args := []string{ + "external-workspaces", + "create", + "my-external-workspace", + "--template", template.Name, + } + inv, root := clitest.New(t, args...) + clitest.SetupConfig(t, member, root) + doneChan := make(chan struct{}) + pty := ptytest.New(t).Attach(inv) + go func() { + defer close(doneChan) + err := inv.Run() + assert.NoError(t, err) + }() + + // Expect the workspace creation confirmation + pty.ExpectMatch("coder_external_agent.main") + pty.ExpectMatch("external-agent (linux, amd64)") + pty.ExpectMatch("Confirm create") + pty.WriteLine("yes") + + // Expect the external agent instructions + pty.ExpectMatch("Please run the following commands to attach external agent") + pty.ExpectMatch("export CODER_AGENT_TOKEN=") + pty.ExpectMatch("curl -fsSL") + + <-doneChan + + // Verify the workspace was created + ws, err := member.WorkspaceByOwnerAndName(context.Background(), codersdk.Me, "my-external-workspace", codersdk.WorkspaceOptions{}) + require.NoError(t, err) + assert.Equal(t, template.Name, ws.TemplateName) + }) + + t.Run("CreateWithoutTemplate", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) + owner := coderdtest.CreateFirstUser(t, client) + member, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID) + + args := []string{ + "external-workspaces", + "create", + "my-external-workspace", + } + inv, root := clitest.New(t, args...) + clitest.SetupConfig(t, member, root) + + err := inv.Run() + require.Error(t, err) + assert.Contains(t, err.Error(), "template name is required for external workspace creation") + }) + + t.Run("CreateWithRegularTemplate", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) + owner := coderdtest.CreateFirstUser(t, client) + member, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID) + version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, completeWithRegularAgent()) + coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) + template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID) + + args := []string{ + "external-workspaces", + "create", + "my-external-workspace", + "--template", template.Name, + } + inv, root := clitest.New(t, args...) + clitest.SetupConfig(t, member, root) + + err := inv.Run() + require.Error(t, err) + assert.Contains(t, err.Error(), "does not have an external agent") + }) + + t.Run("List", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) + owner := coderdtest.CreateFirstUser(t, client) + member, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID) + version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, completeWithExternalAgent()) + coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) + template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID) + + // Create an external workspace + ws := coderdtest.CreateWorkspace(t, member, template.ID) + coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, ws.LatestBuild.ID) + + args := []string{ + "external-workspaces", + "list", + } + inv, root := clitest.New(t, args...) + clitest.SetupConfig(t, member, root) + pty := ptytest.New(t).Attach(inv) + + ctx, cancelFunc := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancelFunc() + done := make(chan any) + go func() { + errC := inv.WithContext(ctx).Run() + assert.NoError(t, errC) + close(done) + }() + pty.ExpectMatch(ws.Name) + pty.ExpectMatch(template.Name) + cancelFunc() + <-done + }) + + t.Run("ListJSON", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) + owner := coderdtest.CreateFirstUser(t, client) + member, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID) + version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, completeWithExternalAgent()) + coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) + template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID) + + // Create an external workspace + ws := coderdtest.CreateWorkspace(t, member, template.ID) + coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, ws.LatestBuild.ID) + + args := []string{ + "external-workspaces", + "list", + "--output=json", + } + inv, root := clitest.New(t, args...) + clitest.SetupConfig(t, member, root) + + ctx, cancelFunc := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancelFunc() + + out := bytes.NewBuffer(nil) + inv.Stdout = out + err := inv.WithContext(ctx).Run() + require.NoError(t, err) + + var workspaces []codersdk.Workspace + require.NoError(t, json.Unmarshal(out.Bytes(), &workspaces)) + require.Len(t, workspaces, 1) + assert.Equal(t, ws.Name, workspaces[0].Name) + }) + + t.Run("ListNoWorkspaces", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) + owner := coderdtest.CreateFirstUser(t, client) + member, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID) + + args := []string{ + "external-workspaces", + "list", + } + inv, root := clitest.New(t, args...) + clitest.SetupConfig(t, member, root) + pty := ptytest.New(t).Attach(inv) + + ctx, cancelFunc := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancelFunc() + done := make(chan any) + go func() { + errC := inv.WithContext(ctx).Run() + assert.NoError(t, errC) + close(done) + }() + pty.ExpectMatch("No workspaces found!") + pty.ExpectMatch("coder external-workspaces create") + cancelFunc() + <-done + }) + + t.Run("AgentInstructions", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) + owner := coderdtest.CreateFirstUser(t, client) + member, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID) + version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, completeWithExternalAgent()) + coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) + template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID) + + // Create an external workspace + ws := coderdtest.CreateWorkspace(t, member, template.ID) + coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, ws.LatestBuild.ID) + + args := []string{ + "external-workspaces", + "agent-instructions", + ws.Name, + "external-agent", + } + inv, root := clitest.New(t, args...) + clitest.SetupConfig(t, member, root) + pty := ptytest.New(t).Attach(inv) + + ctx, cancelFunc := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancelFunc() + done := make(chan any) + go func() { + errC := inv.WithContext(ctx).Run() + assert.NoError(t, errC) + close(done) + }() + pty.ExpectMatch("Please run the following commands to attach agent external-agent:") + pty.ExpectMatch("export CODER_AGENT_TOKEN=") + pty.ExpectMatch("curl -fsSL") + cancelFunc() + <-done + }) + + t.Run("AgentInstructionsJSON", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) + owner := coderdtest.CreateFirstUser(t, client) + member, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID) + version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, completeWithExternalAgent()) + coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) + template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID) + + // Create an external workspace + ws := coderdtest.CreateWorkspace(t, member, template.ID) + coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, ws.LatestBuild.ID) + + args := []string{ + "external-workspaces", + "agent-instructions", + ws.Name, + "external-agent", + "--output=json", + } + inv, root := clitest.New(t, args...) + clitest.SetupConfig(t, member, root) + + ctx, cancelFunc := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancelFunc() + + out := bytes.NewBuffer(nil) + inv.Stdout = out + err := inv.WithContext(ctx).Run() + require.NoError(t, err) + + var agentInfo map[string]interface{} + require.NoError(t, json.Unmarshal(out.Bytes(), &agentInfo)) + assert.Equal(t, "token", agentInfo["auth_type"]) + assert.NotEmpty(t, agentInfo["auth_token"]) + assert.Contains(t, agentInfo["init_script"], "/api/v2/init-script") + }) + + t.Run("AgentInstructionsNonExistentWorkspace", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) + owner := coderdtest.CreateFirstUser(t, client) + member, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID) + + args := []string{ + "external-workspaces", + "agent-instructions", + "non-existent-workspace", + "external-agent", + } + inv, root := clitest.New(t, args...) + clitest.SetupConfig(t, member, root) + + err := inv.Run() + require.Error(t, err) + assert.Contains(t, err.Error(), "get workspace by name") + }) + + t.Run("AgentInstructionsNonExistentAgent", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) + owner := coderdtest.CreateFirstUser(t, client) + member, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID) + version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, completeWithExternalAgent()) + coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) + template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID) + + // Create an external workspace + ws := coderdtest.CreateWorkspace(t, member, template.ID) + coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, ws.LatestBuild.ID) + + args := []string{ + "external-workspaces", + "agent-instructions", + ws.Name, + "non-existent-agent", + } + inv, root := clitest.New(t, args...) + clitest.SetupConfig(t, member, root) + + err := inv.Run() + require.Error(t, err) + assert.Contains(t, err.Error(), "get external agent token for agent") + }) + + t.Run("CreateWithTemplateVersion", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) + owner := coderdtest.CreateFirstUser(t, client) + member, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID) + version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, completeWithExternalAgent()) + coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) + template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID) + + args := []string{ + "external-workspaces", + "create", + "my-external-workspace", + "--template", template.Name, + "--template-version", version.Name, + "-y", + } + inv, root := clitest.New(t, args...) + clitest.SetupConfig(t, member, root) + doneChan := make(chan struct{}) + pty := ptytest.New(t).Attach(inv) + go func() { + defer close(doneChan) + err := inv.Run() + assert.NoError(t, err) + }() + + // Expect the workspace creation confirmation + pty.ExpectMatch("coder_external_agent.main") + pty.ExpectMatch("external-agent (linux, amd64)") + + // Expect the external agent instructions + pty.ExpectMatch("Please run the following commands to attach external agent") + pty.ExpectMatch("export CODER_AGENT_TOKEN=") + pty.ExpectMatch("curl -fsSL") + + <-doneChan + + // Verify the workspace was created + ws, err := member.WorkspaceByOwnerAndName(context.Background(), codersdk.Me, "my-external-workspace", codersdk.WorkspaceOptions{}) + require.NoError(t, err) + assert.Equal(t, template.Name, ws.TemplateName) + }) +} diff --git a/cli/start.go b/cli/start.go index bd6578066de1d..66c96cc9c4d75 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_external-workspaces_create_--help.golden b/cli/testdata/coder_external-workspaces_create_--help.golden index f2ebc96fa6d98..208d2cc2296d7 100644 --- a/cli/testdata/coder_external-workspaces_create_--help.golden +++ b/cli/testdata/coder_external-workspaces_create_--help.golden @@ -26,6 +26,10 @@ OPTIONS: --parameter-default string-array, $CODER_RICH_PARAMETER_DEFAULT Rich parameter default values in the format "name=value". + --preset string, $CODER_PRESET_NAME + Specify the name of a template version preset. Use 'none' to + explicitly indicate that no preset should be used. + --rich-parameter-file string, $CODER_RICH_PARAMETER_FILE Specify a file path with values for rich parameters defined in the template. The file should be in YAML format, containing key-value diff --git a/coderd/database/dbgen/dbgen.go b/coderd/database/dbgen/dbgen.go index 81d9efd1cd3e3..50b1125ef98e7 100644 --- a/coderd/database/dbgen/dbgen.go +++ b/coderd/database/dbgen/dbgen.go @@ -437,6 +437,7 @@ func WorkspaceBuild(t testing.TB, db database.Store, orig database.WorkspaceBuil jobID := takeFirst(orig.JobID, uuid.New()) hasAITask := takeFirst(orig.HasAITask, sql.NullBool{}) sidebarAppID := takeFirst(orig.AITaskSidebarAppID, uuid.NullUUID{}) + hasExternalAgent := takeFirst(orig.HasExternalAgent, sql.NullBool{}) var build database.WorkspaceBuild err := db.InTx(func(db database.Store) error { err := db.InsertWorkspaceBuild(genCtx, database.InsertWorkspaceBuildParams{ @@ -479,6 +480,14 @@ func WorkspaceBuild(t testing.TB, db database.Store, orig database.WorkspaceBuil })) } + if hasExternalAgent.Valid { + require.NoError(t, db.UpdateWorkspaceBuildExternalAgentByID(genCtx, database.UpdateWorkspaceBuildExternalAgentByIDParams{ + ID: buildID, + HasExternalAgent: hasExternalAgent, + UpdatedAt: dbtime.Now(), + })) + } + build, err = db.GetWorkspaceBuildByID(genCtx, buildID) if err != nil { return err @@ -1028,6 +1037,7 @@ func ExternalAuthLink(t testing.TB, db database.Store, orig database.ExternalAut func TemplateVersion(t testing.TB, db database.Store, orig database.TemplateVersion) database.TemplateVersion { var version database.TemplateVersion hasAITask := takeFirst(orig.HasAITask, sql.NullBool{}) + hasExternalAgent := takeFirst(orig.HasExternalAgent, sql.NullBool{}) jobID := takeFirst(orig.JobID, uuid.New()) err := db.InTx(func(db database.Store) error { versionID := takeFirst(orig.ID, uuid.New()) @@ -1056,6 +1066,14 @@ func TemplateVersion(t testing.TB, db database.Store, orig database.TemplateVers })) } + if hasExternalAgent.Valid { + require.NoError(t, db.UpdateTemplateVersionExternalAgentByJobID(genCtx, database.UpdateTemplateVersionExternalAgentByJobIDParams{ + JobID: jobID, + HasExternalAgent: hasExternalAgent, + UpdatedAt: dbtime.Now(), + })) + } + version, err = db.GetTemplateVersionByID(genCtx, versionID) if err != nil { return err diff --git a/coderd/database/migrations/000351_external_agents.down.sql b/coderd/database/migrations/000356_external_agents.down.sql similarity index 100% rename from coderd/database/migrations/000351_external_agents.down.sql rename to coderd/database/migrations/000356_external_agents.down.sql diff --git a/coderd/database/migrations/000351_external_agents.up.sql b/coderd/database/migrations/000356_external_agents.up.sql similarity index 100% rename from coderd/database/migrations/000351_external_agents.up.sql rename to coderd/database/migrations/000356_external_agents.up.sql diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 6033ab728007d..8bd40b437b02c 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -12059,19 +12059,26 @@ WHERE tv.has_ai_task = $7 :: boolean ELSE true END + -- Filter by has_external_agent in latest version + AND CASE + WHEN $8 :: boolean IS NOT NULL THEN + tv.has_external_agent = $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"` + HasExternalAgent sql.NullBool `db:"has_external_agent" json:"has_external_agent"` } func (q *sqlQuerier) GetTemplatesWithFilter(ctx context.Context, arg GetTemplatesWithFilterParams) ([]Template, error) { @@ -12083,6 +12090,7 @@ func (q *sqlQuerier) GetTemplatesWithFilter(ctx context.Context, arg GetTemplate pq.Array(arg.IDs), arg.Deprecated, arg.HasAITask, + arg.HasExternalAgent, ) if err != nil { return nil, err @@ -12567,7 +12575,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_agent FROM template_versions WHERE @@ -12668,7 +12676,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_agent, created_by_avatar_url, created_by_username, created_by_name FROM template_version_with_user AS template_versions WHERE @@ -12707,6 +12715,7 @@ func (q *sqlQuerier) GetPreviousTemplateVersion(ctx context.Context, arg GetPrev &i.Archived, &i.SourceExampleID, &i.HasAITask, + &i.HasExternalAgent, &i.CreatedByAvatarURL, &i.CreatedByUsername, &i.CreatedByName, @@ -12716,7 +12725,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_agent, created_by_avatar_url, created_by_username, created_by_name FROM template_version_with_user AS template_versions WHERE @@ -12741,6 +12750,7 @@ func (q *sqlQuerier) GetTemplateVersionByID(ctx context.Context, id uuid.UUID) ( &i.Archived, &i.SourceExampleID, &i.HasAITask, + &i.HasExternalAgent, &i.CreatedByAvatarURL, &i.CreatedByUsername, &i.CreatedByName, @@ -12750,7 +12760,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_agent, created_by_avatar_url, created_by_username, created_by_name FROM template_version_with_user AS template_versions WHERE @@ -12775,6 +12785,7 @@ func (q *sqlQuerier) GetTemplateVersionByJobID(ctx context.Context, jobID uuid.U &i.Archived, &i.SourceExampleID, &i.HasAITask, + &i.HasExternalAgent, &i.CreatedByAvatarURL, &i.CreatedByUsername, &i.CreatedByName, @@ -12784,7 +12795,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_agent, created_by_avatar_url, created_by_username, created_by_name FROM template_version_with_user AS template_versions WHERE @@ -12815,6 +12826,7 @@ func (q *sqlQuerier) GetTemplateVersionByTemplateIDAndName(ctx context.Context, &i.Archived, &i.SourceExampleID, &i.HasAITask, + &i.HasExternalAgent, &i.CreatedByAvatarURL, &i.CreatedByUsername, &i.CreatedByName, @@ -12824,7 +12836,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_agent, created_by_avatar_url, created_by_username, created_by_name FROM template_version_with_user AS template_versions WHERE @@ -12855,6 +12867,7 @@ func (q *sqlQuerier) GetTemplateVersionsByIDs(ctx context.Context, ids []uuid.UU &i.Archived, &i.SourceExampleID, &i.HasAITask, + &i.HasExternalAgent, &i.CreatedByAvatarURL, &i.CreatedByUsername, &i.CreatedByName, @@ -12874,7 +12887,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_agent, created_by_avatar_url, created_by_username, created_by_name FROM template_version_with_user AS template_versions WHERE @@ -12952,6 +12965,7 @@ func (q *sqlQuerier) GetTemplateVersionsByTemplateID(ctx context.Context, arg Ge &i.Archived, &i.SourceExampleID, &i.HasAITask, + &i.HasExternalAgent, &i.CreatedByAvatarURL, &i.CreatedByUsername, &i.CreatedByName, @@ -12970,7 +12984,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_agent, 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) { @@ -12997,6 +13011,7 @@ func (q *sqlQuerier) GetTemplateVersionsCreatedAfter(ctx context.Context, create &i.Archived, &i.SourceExampleID, &i.HasAITask, + &i.HasExternalAgent, &i.CreatedByAvatarURL, &i.CreatedByUsername, &i.CreatedByName, @@ -13170,6 +13185,27 @@ func (q *sqlQuerier) UpdateTemplateVersionDescriptionByJobID(ctx context.Context return err } +const updateTemplateVersionExternalAgentByJobID = `-- name: UpdateTemplateVersionExternalAgentByJobID :exec +UPDATE + template_versions +SET + has_external_agent = $2, + updated_at = $3 +WHERE + job_id = $1 +` + +type UpdateTemplateVersionExternalAgentByJobIDParams struct { + JobID uuid.UUID `db:"job_id" json:"job_id"` + HasExternalAgent sql.NullBool `db:"has_external_agent" json:"has_external_agent"` + UpdatedAt time.Time `db:"updated_at" json:"updated_at"` +} + +func (q *sqlQuerier) UpdateTemplateVersionExternalAgentByJobID(ctx context.Context, arg UpdateTemplateVersionExternalAgentByJobIDParams) error { + _, err := q.db.ExecContext(ctx, updateTemplateVersionExternalAgentByJobID, arg.JobID, arg.HasExternalAgent, arg.UpdatedAt) + return err +} + const updateTemplateVersionExternalAuthProvidersByJobID = `-- name: UpdateTemplateVersionExternalAuthProvidersByJobID :exec UPDATE template_versions @@ -15443,7 +15479,7 @@ const getWorkspaceAgentAndLatestBuildByAuthToken = `-- name: GetWorkspaceAgentAn SELECT workspaces.id, workspaces.created_at, workspaces.updated_at, workspaces.owner_id, workspaces.organization_id, workspaces.template_id, workspaces.deleted, workspaces.name, workspaces.autostart_schedule, workspaces.ttl, workspaces.last_used_at, workspaces.dormant_at, workspaces.deleting_at, workspaces.automatic_updates, workspaces.favorite, workspaces.next_start_at, workspaces.group_acl, workspaces.user_acl, workspace_agents.id, workspace_agents.created_at, workspace_agents.updated_at, workspace_agents.name, workspace_agents.first_connected_at, workspace_agents.last_connected_at, workspace_agents.disconnected_at, workspace_agents.resource_id, workspace_agents.auth_token, workspace_agents.auth_instance_id, workspace_agents.architecture, workspace_agents.environment_variables, workspace_agents.operating_system, workspace_agents.instance_metadata, workspace_agents.resource_metadata, workspace_agents.directory, workspace_agents.version, workspace_agents.last_connected_replica_id, workspace_agents.connection_timeout_seconds, workspace_agents.troubleshooting_url, workspace_agents.motd_file, workspace_agents.lifecycle_state, workspace_agents.expanded_directory, workspace_agents.logs_length, workspace_agents.logs_overflowed, workspace_agents.started_at, workspace_agents.ready_at, workspace_agents.subsystems, workspace_agents.display_apps, workspace_agents.api_version, workspace_agents.display_order, workspace_agents.parent_id, workspace_agents.api_key_scope, workspace_agents.deleted, - workspace_build_with_user.id, workspace_build_with_user.created_at, workspace_build_with_user.updated_at, workspace_build_with_user.workspace_id, workspace_build_with_user.template_version_id, workspace_build_with_user.build_number, workspace_build_with_user.transition, workspace_build_with_user.initiator_id, workspace_build_with_user.provisioner_state, workspace_build_with_user.job_id, workspace_build_with_user.deadline, workspace_build_with_user.reason, workspace_build_with_user.daily_cost, workspace_build_with_user.max_deadline, workspace_build_with_user.template_version_preset_id, workspace_build_with_user.has_ai_task, workspace_build_with_user.ai_task_sidebar_app_id, workspace_build_with_user.initiator_by_avatar_url, workspace_build_with_user.initiator_by_username, workspace_build_with_user.initiator_by_name + workspace_build_with_user.id, workspace_build_with_user.created_at, workspace_build_with_user.updated_at, workspace_build_with_user.workspace_id, workspace_build_with_user.template_version_id, workspace_build_with_user.build_number, workspace_build_with_user.transition, workspace_build_with_user.initiator_id, workspace_build_with_user.provisioner_state, workspace_build_with_user.job_id, workspace_build_with_user.deadline, workspace_build_with_user.reason, workspace_build_with_user.daily_cost, workspace_build_with_user.max_deadline, workspace_build_with_user.template_version_preset_id, workspace_build_with_user.has_ai_task, workspace_build_with_user.ai_task_sidebar_app_id, workspace_build_with_user.has_external_agent, workspace_build_with_user.initiator_by_avatar_url, workspace_build_with_user.initiator_by_username, workspace_build_with_user.initiator_by_name FROM workspace_agents JOIN @@ -15556,6 +15592,7 @@ func (q *sqlQuerier) GetWorkspaceAgentAndLatestBuildByAuthToken(ctx context.Cont &i.WorkspaceBuild.TemplateVersionPresetID, &i.WorkspaceBuild.HasAITask, &i.WorkspaceBuild.AITaskSidebarAppID, + &i.WorkspaceBuild.HasExternalAgent, &i.WorkspaceBuild.InitiatorByAvatarUrl, &i.WorkspaceBuild.InitiatorByUsername, &i.WorkspaceBuild.InitiatorByName, @@ -18210,7 +18247,7 @@ func (q *sqlQuerier) InsertWorkspaceBuildParameters(ctx context.Context, arg Ins } const getActiveWorkspaceBuildsByTemplateID = `-- name: GetActiveWorkspaceBuildsByTemplateID :many -SELECT wb.id, wb.created_at, wb.updated_at, wb.workspace_id, wb.template_version_id, wb.build_number, wb.transition, wb.initiator_id, wb.provisioner_state, wb.job_id, wb.deadline, wb.reason, wb.daily_cost, wb.max_deadline, wb.template_version_preset_id, wb.has_ai_task, wb.ai_task_sidebar_app_id, wb.initiator_by_avatar_url, wb.initiator_by_username, wb.initiator_by_name +SELECT wb.id, wb.created_at, wb.updated_at, wb.workspace_id, wb.template_version_id, wb.build_number, wb.transition, wb.initiator_id, wb.provisioner_state, wb.job_id, wb.deadline, wb.reason, wb.daily_cost, wb.max_deadline, wb.template_version_preset_id, wb.has_ai_task, wb.ai_task_sidebar_app_id, wb.has_external_agent, wb.initiator_by_avatar_url, wb.initiator_by_username, wb.initiator_by_name FROM ( SELECT workspace_id, MAX(build_number) as max_build_number @@ -18267,6 +18304,7 @@ func (q *sqlQuerier) GetActiveWorkspaceBuildsByTemplateID(ctx context.Context, t &i.TemplateVersionPresetID, &i.HasAITask, &i.AITaskSidebarAppID, + &i.HasExternalAgent, &i.InitiatorByAvatarUrl, &i.InitiatorByUsername, &i.InitiatorByName, @@ -18366,7 +18404,7 @@ func (q *sqlQuerier) GetFailedWorkspaceBuildsByTemplateID(ctx context.Context, a const getLatestWorkspaceBuildByWorkspaceID = `-- name: GetLatestWorkspaceBuildByWorkspaceID :one SELECT - id, created_at, updated_at, workspace_id, template_version_id, build_number, transition, initiator_id, provisioner_state, job_id, deadline, reason, daily_cost, max_deadline, template_version_preset_id, has_ai_task, ai_task_sidebar_app_id, initiator_by_avatar_url, initiator_by_username, initiator_by_name + id, created_at, updated_at, workspace_id, template_version_id, build_number, transition, initiator_id, provisioner_state, job_id, deadline, reason, daily_cost, max_deadline, template_version_preset_id, has_ai_task, ai_task_sidebar_app_id, has_external_agent, initiator_by_avatar_url, initiator_by_username, initiator_by_name FROM workspace_build_with_user AS workspace_builds WHERE @@ -18398,6 +18436,7 @@ func (q *sqlQuerier) GetLatestWorkspaceBuildByWorkspaceID(ctx context.Context, w &i.TemplateVersionPresetID, &i.HasAITask, &i.AITaskSidebarAppID, + &i.HasExternalAgent, &i.InitiatorByAvatarUrl, &i.InitiatorByUsername, &i.InitiatorByName, @@ -18406,7 +18445,7 @@ func (q *sqlQuerier) GetLatestWorkspaceBuildByWorkspaceID(ctx context.Context, w } const getLatestWorkspaceBuilds = `-- name: GetLatestWorkspaceBuilds :many -SELECT wb.id, wb.created_at, wb.updated_at, wb.workspace_id, wb.template_version_id, wb.build_number, wb.transition, wb.initiator_id, wb.provisioner_state, wb.job_id, wb.deadline, wb.reason, wb.daily_cost, wb.max_deadline, wb.template_version_preset_id, wb.has_ai_task, wb.ai_task_sidebar_app_id, wb.initiator_by_avatar_url, wb.initiator_by_username, wb.initiator_by_name +SELECT wb.id, wb.created_at, wb.updated_at, wb.workspace_id, wb.template_version_id, wb.build_number, wb.transition, wb.initiator_id, wb.provisioner_state, wb.job_id, wb.deadline, wb.reason, wb.daily_cost, wb.max_deadline, wb.template_version_preset_id, wb.has_ai_task, wb.ai_task_sidebar_app_id, wb.has_external_agent, wb.initiator_by_avatar_url, wb.initiator_by_username, wb.initiator_by_name FROM ( SELECT workspace_id, MAX(build_number) as max_build_number @@ -18447,6 +18486,7 @@ func (q *sqlQuerier) GetLatestWorkspaceBuilds(ctx context.Context) ([]WorkspaceB &i.TemplateVersionPresetID, &i.HasAITask, &i.AITaskSidebarAppID, + &i.HasExternalAgent, &i.InitiatorByAvatarUrl, &i.InitiatorByUsername, &i.InitiatorByName, @@ -18465,7 +18505,7 @@ func (q *sqlQuerier) GetLatestWorkspaceBuilds(ctx context.Context) ([]WorkspaceB } const getLatestWorkspaceBuildsByWorkspaceIDs = `-- name: GetLatestWorkspaceBuildsByWorkspaceIDs :many -SELECT wb.id, wb.created_at, wb.updated_at, wb.workspace_id, wb.template_version_id, wb.build_number, wb.transition, wb.initiator_id, wb.provisioner_state, wb.job_id, wb.deadline, wb.reason, wb.daily_cost, wb.max_deadline, wb.template_version_preset_id, wb.has_ai_task, wb.ai_task_sidebar_app_id, wb.initiator_by_avatar_url, wb.initiator_by_username, wb.initiator_by_name +SELECT wb.id, wb.created_at, wb.updated_at, wb.workspace_id, wb.template_version_id, wb.build_number, wb.transition, wb.initiator_id, wb.provisioner_state, wb.job_id, wb.deadline, wb.reason, wb.daily_cost, wb.max_deadline, wb.template_version_preset_id, wb.has_ai_task, wb.ai_task_sidebar_app_id, wb.has_external_agent, wb.initiator_by_avatar_url, wb.initiator_by_username, wb.initiator_by_name FROM ( SELECT workspace_id, MAX(build_number) as max_build_number @@ -18508,6 +18548,7 @@ func (q *sqlQuerier) GetLatestWorkspaceBuildsByWorkspaceIDs(ctx context.Context, &i.TemplateVersionPresetID, &i.HasAITask, &i.AITaskSidebarAppID, + &i.HasExternalAgent, &i.InitiatorByAvatarUrl, &i.InitiatorByUsername, &i.InitiatorByName, @@ -18527,7 +18568,7 @@ func (q *sqlQuerier) GetLatestWorkspaceBuildsByWorkspaceIDs(ctx context.Context, const getWorkspaceBuildByID = `-- name: GetWorkspaceBuildByID :one SELECT - id, created_at, updated_at, workspace_id, template_version_id, build_number, transition, initiator_id, provisioner_state, job_id, deadline, reason, daily_cost, max_deadline, template_version_preset_id, has_ai_task, ai_task_sidebar_app_id, initiator_by_avatar_url, initiator_by_username, initiator_by_name + id, created_at, updated_at, workspace_id, template_version_id, build_number, transition, initiator_id, provisioner_state, job_id, deadline, reason, daily_cost, max_deadline, template_version_preset_id, has_ai_task, ai_task_sidebar_app_id, has_external_agent, initiator_by_avatar_url, initiator_by_username, initiator_by_name FROM workspace_build_with_user AS workspace_builds WHERE @@ -18557,6 +18598,7 @@ func (q *sqlQuerier) GetWorkspaceBuildByID(ctx context.Context, id uuid.UUID) (W &i.TemplateVersionPresetID, &i.HasAITask, &i.AITaskSidebarAppID, + &i.HasExternalAgent, &i.InitiatorByAvatarUrl, &i.InitiatorByUsername, &i.InitiatorByName, @@ -18566,7 +18608,7 @@ func (q *sqlQuerier) GetWorkspaceBuildByID(ctx context.Context, id uuid.UUID) (W const getWorkspaceBuildByJobID = `-- name: GetWorkspaceBuildByJobID :one SELECT - id, created_at, updated_at, workspace_id, template_version_id, build_number, transition, initiator_id, provisioner_state, job_id, deadline, reason, daily_cost, max_deadline, template_version_preset_id, has_ai_task, ai_task_sidebar_app_id, initiator_by_avatar_url, initiator_by_username, initiator_by_name + id, created_at, updated_at, workspace_id, template_version_id, build_number, transition, initiator_id, provisioner_state, job_id, deadline, reason, daily_cost, max_deadline, template_version_preset_id, has_ai_task, ai_task_sidebar_app_id, has_external_agent, initiator_by_avatar_url, initiator_by_username, initiator_by_name FROM workspace_build_with_user AS workspace_builds WHERE @@ -18596,6 +18638,7 @@ func (q *sqlQuerier) GetWorkspaceBuildByJobID(ctx context.Context, jobID uuid.UU &i.TemplateVersionPresetID, &i.HasAITask, &i.AITaskSidebarAppID, + &i.HasExternalAgent, &i.InitiatorByAvatarUrl, &i.InitiatorByUsername, &i.InitiatorByName, @@ -18605,7 +18648,7 @@ func (q *sqlQuerier) GetWorkspaceBuildByJobID(ctx context.Context, jobID uuid.UU const getWorkspaceBuildByWorkspaceIDAndBuildNumber = `-- name: GetWorkspaceBuildByWorkspaceIDAndBuildNumber :one SELECT - id, created_at, updated_at, workspace_id, template_version_id, build_number, transition, initiator_id, provisioner_state, job_id, deadline, reason, daily_cost, max_deadline, template_version_preset_id, has_ai_task, ai_task_sidebar_app_id, initiator_by_avatar_url, initiator_by_username, initiator_by_name + id, created_at, updated_at, workspace_id, template_version_id, build_number, transition, initiator_id, provisioner_state, job_id, deadline, reason, daily_cost, max_deadline, template_version_preset_id, has_ai_task, ai_task_sidebar_app_id, has_external_agent, initiator_by_avatar_url, initiator_by_username, initiator_by_name FROM workspace_build_with_user AS workspace_builds WHERE @@ -18639,6 +18682,7 @@ func (q *sqlQuerier) GetWorkspaceBuildByWorkspaceIDAndBuildNumber(ctx context.Co &i.TemplateVersionPresetID, &i.HasAITask, &i.AITaskSidebarAppID, + &i.HasExternalAgent, &i.InitiatorByAvatarUrl, &i.InitiatorByUsername, &i.InitiatorByName, @@ -18715,7 +18759,7 @@ func (q *sqlQuerier) GetWorkspaceBuildStatsByTemplates(ctx context.Context, sinc const getWorkspaceBuildsByWorkspaceID = `-- name: GetWorkspaceBuildsByWorkspaceID :many SELECT - id, created_at, updated_at, workspace_id, template_version_id, build_number, transition, initiator_id, provisioner_state, job_id, deadline, reason, daily_cost, max_deadline, template_version_preset_id, has_ai_task, ai_task_sidebar_app_id, initiator_by_avatar_url, initiator_by_username, initiator_by_name + id, created_at, updated_at, workspace_id, template_version_id, build_number, transition, initiator_id, provisioner_state, job_id, deadline, reason, daily_cost, max_deadline, template_version_preset_id, has_ai_task, ai_task_sidebar_app_id, has_external_agent, initiator_by_avatar_url, initiator_by_username, initiator_by_name FROM workspace_build_with_user AS workspace_builds WHERE @@ -18788,6 +18832,7 @@ func (q *sqlQuerier) GetWorkspaceBuildsByWorkspaceID(ctx context.Context, arg Ge &i.TemplateVersionPresetID, &i.HasAITask, &i.AITaskSidebarAppID, + &i.HasExternalAgent, &i.InitiatorByAvatarUrl, &i.InitiatorByUsername, &i.InitiatorByName, @@ -18806,7 +18851,7 @@ func (q *sqlQuerier) GetWorkspaceBuildsByWorkspaceID(ctx context.Context, arg Ge } const getWorkspaceBuildsCreatedAfter = `-- name: GetWorkspaceBuildsCreatedAfter :many -SELECT id, created_at, updated_at, workspace_id, template_version_id, build_number, transition, initiator_id, provisioner_state, job_id, deadline, reason, daily_cost, max_deadline, template_version_preset_id, has_ai_task, ai_task_sidebar_app_id, initiator_by_avatar_url, initiator_by_username, initiator_by_name FROM workspace_build_with_user WHERE created_at > $1 +SELECT id, created_at, updated_at, workspace_id, template_version_id, build_number, transition, initiator_id, provisioner_state, job_id, deadline, reason, daily_cost, max_deadline, template_version_preset_id, has_ai_task, ai_task_sidebar_app_id, has_external_agent, initiator_by_avatar_url, initiator_by_username, initiator_by_name FROM workspace_build_with_user WHERE created_at > $1 ` func (q *sqlQuerier) GetWorkspaceBuildsCreatedAfter(ctx context.Context, createdAt time.Time) ([]WorkspaceBuild, error) { @@ -18836,6 +18881,7 @@ func (q *sqlQuerier) GetWorkspaceBuildsCreatedAfter(ctx context.Context, created &i.TemplateVersionPresetID, &i.HasAITask, &i.AITaskSidebarAppID, + &i.HasExternalAgent, &i.InitiatorByAvatarUrl, &i.InitiatorByUsername, &i.InitiatorByName, @@ -18985,6 +19031,26 @@ func (q *sqlQuerier) UpdateWorkspaceBuildDeadlineByID(ctx context.Context, arg U return err } +const updateWorkspaceBuildExternalAgentByID = `-- name: UpdateWorkspaceBuildExternalAgentByID :exec +UPDATE + workspace_builds +SET + has_external_agent = $1, + updated_at = $2::timestamptz +WHERE id = $3::uuid +` + +type UpdateWorkspaceBuildExternalAgentByIDParams struct { + HasExternalAgent sql.NullBool `db:"has_external_agent" json:"has_external_agent"` + UpdatedAt time.Time `db:"updated_at" json:"updated_at"` + ID uuid.UUID `db:"id" json:"id"` +} + +func (q *sqlQuerier) UpdateWorkspaceBuildExternalAgentByID(ctx context.Context, arg UpdateWorkspaceBuildExternalAgentByIDParams) error { + _, err := q.db.ExecContext(ctx, updateWorkspaceBuildExternalAgentByID, arg.HasExternalAgent, arg.UpdatedAt, arg.ID) + return err +} + const updateWorkspaceBuildProvisionerStateByID = `-- name: UpdateWorkspaceBuildProvisionerStateByID :exec UPDATE workspace_builds @@ -19952,7 +20018,8 @@ SELECT latest_build.error as latest_build_error, latest_build.transition as latest_build_transition, latest_build.job_status as latest_build_status, - latest_build.has_ai_task as latest_build_has_ai_task + latest_build.has_ai_task as latest_build_has_ai_task, + latest_build.has_external_agent as latest_build_has_external_agent FROM workspaces_expanded as workspaces JOIN @@ -19965,6 +20032,7 @@ LEFT JOIN LATERAL ( workspace_builds.transition, workspace_builds.template_version_id, workspace_builds.has_ai_task, + workspace_builds.has_external_agent, template_versions.name AS template_version_name, provisioner_jobs.id AS provisioner_job_id, provisioner_jobs.started_at, @@ -20205,16 +20273,22 @@ WHERE )) = ($19 :: boolean) ELSE true END + -- Filter by has_external_agent in latest build + AND CASE + WHEN $20 :: boolean IS NOT NULL THEN + latest_build.has_external_agent = $20 :: boolean + ELSE true + END -- Authorize Filter clause will be injected below in GetAuthorizedWorkspaces -- @authorize_filter ), filtered_workspaces_order AS ( SELECT - fw.id, fw.created_at, fw.updated_at, fw.owner_id, fw.organization_id, fw.template_id, fw.deleted, fw.name, fw.autostart_schedule, fw.ttl, fw.last_used_at, fw.dormant_at, fw.deleting_at, fw.automatic_updates, fw.favorite, fw.next_start_at, fw.group_acl, fw.user_acl, fw.owner_avatar_url, fw.owner_username, fw.owner_name, fw.organization_name, fw.organization_display_name, fw.organization_icon, fw.organization_description, fw.template_name, fw.template_display_name, fw.template_icon, fw.template_description, fw.template_version_id, fw.template_version_name, fw.latest_build_completed_at, fw.latest_build_canceled_at, fw.latest_build_error, fw.latest_build_transition, fw.latest_build_status, fw.latest_build_has_ai_task + fw.id, fw.created_at, fw.updated_at, fw.owner_id, fw.organization_id, fw.template_id, fw.deleted, fw.name, fw.autostart_schedule, fw.ttl, fw.last_used_at, fw.dormant_at, fw.deleting_at, fw.automatic_updates, fw.favorite, fw.next_start_at, fw.group_acl, fw.user_acl, fw.owner_avatar_url, fw.owner_username, fw.owner_name, fw.organization_name, fw.organization_display_name, fw.organization_icon, fw.organization_description, fw.template_name, fw.template_display_name, fw.template_icon, fw.template_description, fw.template_version_id, fw.template_version_name, fw.latest_build_completed_at, fw.latest_build_canceled_at, fw.latest_build_error, fw.latest_build_transition, fw.latest_build_status, fw.latest_build_has_ai_task, fw.latest_build_has_external_agent FROM filtered_workspaces fw ORDER BY -- To ensure that 'favorite' workspaces show up first in the list only for their owner. - CASE WHEN owner_id = $20 AND favorite THEN 0 ELSE 1 END ASC, + CASE WHEN owner_id = $21 AND favorite THEN 0 ELSE 1 END ASC, (latest_build_completed_at IS NOT NULL AND latest_build_canceled_at IS NULL AND latest_build_error IS NULL AND @@ -20223,14 +20297,14 @@ WHERE LOWER(name) ASC LIMIT CASE - WHEN $22 :: integer > 0 THEN - $22 + WHEN $23 :: integer > 0 THEN + $23 END OFFSET - $21 + $22 ), filtered_workspaces_order_with_summary AS ( SELECT - fwo.id, fwo.created_at, fwo.updated_at, fwo.owner_id, fwo.organization_id, fwo.template_id, fwo.deleted, fwo.name, fwo.autostart_schedule, fwo.ttl, fwo.last_used_at, fwo.dormant_at, fwo.deleting_at, fwo.automatic_updates, fwo.favorite, fwo.next_start_at, fwo.group_acl, fwo.user_acl, fwo.owner_avatar_url, fwo.owner_username, fwo.owner_name, fwo.organization_name, fwo.organization_display_name, fwo.organization_icon, fwo.organization_description, fwo.template_name, fwo.template_display_name, fwo.template_icon, fwo.template_description, fwo.template_version_id, fwo.template_version_name, fwo.latest_build_completed_at, fwo.latest_build_canceled_at, fwo.latest_build_error, fwo.latest_build_transition, fwo.latest_build_status, fwo.latest_build_has_ai_task + fwo.id, fwo.created_at, fwo.updated_at, fwo.owner_id, fwo.organization_id, fwo.template_id, fwo.deleted, fwo.name, fwo.autostart_schedule, fwo.ttl, fwo.last_used_at, fwo.dormant_at, fwo.deleting_at, fwo.automatic_updates, fwo.favorite, fwo.next_start_at, fwo.group_acl, fwo.user_acl, fwo.owner_avatar_url, fwo.owner_username, fwo.owner_name, fwo.organization_name, fwo.organization_display_name, fwo.organization_icon, fwo.organization_description, fwo.template_name, fwo.template_display_name, fwo.template_icon, fwo.template_description, fwo.template_version_id, fwo.template_version_name, fwo.latest_build_completed_at, fwo.latest_build_canceled_at, fwo.latest_build_error, fwo.latest_build_transition, fwo.latest_build_status, fwo.latest_build_has_ai_task, fwo.latest_build_has_external_agent FROM filtered_workspaces_order fwo -- Return a technical summary row with total count of workspaces. @@ -20274,9 +20348,10 @@ WHERE '', -- latest_build_error 'start'::workspace_transition, -- latest_build_transition 'unknown'::provisioner_job_status, -- latest_build_status - false -- latest_build_has_ai_task + false, -- latest_build_has_ai_task + false -- latest_build_has_external_agent WHERE - $23 :: boolean = true + $24 :: boolean = true ), total_count AS ( SELECT count(*) AS count @@ -20284,7 +20359,7 @@ WHERE filtered_workspaces ) SELECT - fwos.id, fwos.created_at, fwos.updated_at, fwos.owner_id, fwos.organization_id, fwos.template_id, fwos.deleted, fwos.name, fwos.autostart_schedule, fwos.ttl, fwos.last_used_at, fwos.dormant_at, fwos.deleting_at, fwos.automatic_updates, fwos.favorite, fwos.next_start_at, fwos.group_acl, fwos.user_acl, fwos.owner_avatar_url, fwos.owner_username, fwos.owner_name, fwos.organization_name, fwos.organization_display_name, fwos.organization_icon, fwos.organization_description, fwos.template_name, fwos.template_display_name, fwos.template_icon, fwos.template_description, fwos.template_version_id, fwos.template_version_name, fwos.latest_build_completed_at, fwos.latest_build_canceled_at, fwos.latest_build_error, fwos.latest_build_transition, fwos.latest_build_status, fwos.latest_build_has_ai_task, + fwos.id, fwos.created_at, fwos.updated_at, fwos.owner_id, fwos.organization_id, fwos.template_id, fwos.deleted, fwos.name, fwos.autostart_schedule, fwos.ttl, fwos.last_used_at, fwos.dormant_at, fwos.deleting_at, fwos.automatic_updates, fwos.favorite, fwos.next_start_at, fwos.group_acl, fwos.user_acl, fwos.owner_avatar_url, fwos.owner_username, fwos.owner_name, fwos.organization_name, fwos.organization_display_name, fwos.organization_icon, fwos.organization_description, fwos.template_name, fwos.template_display_name, fwos.template_icon, fwos.template_description, fwos.template_version_id, fwos.template_version_name, fwos.latest_build_completed_at, fwos.latest_build_canceled_at, fwos.latest_build_error, fwos.latest_build_transition, fwos.latest_build_status, fwos.latest_build_has_ai_task, fwos.latest_build_has_external_agent, tc.count FROM filtered_workspaces_order_with_summary fwos @@ -20312,6 +20387,7 @@ type GetWorkspacesParams struct { LastUsedAfter time.Time `db:"last_used_after" json:"last_used_after"` UsingActive sql.NullBool `db:"using_active" json:"using_active"` HasAITask sql.NullBool `db:"has_ai_task" json:"has_ai_task"` + HasExternalAgent sql.NullBool `db:"has_external_agent" json:"has_external_agent"` RequesterID uuid.UUID `db:"requester_id" json:"requester_id"` Offset int32 `db:"offset_" json:"offset_"` Limit int32 `db:"limit_" json:"limit_"` @@ -20319,44 +20395,45 @@ type GetWorkspacesParams struct { } type GetWorkspacesRow struct { - ID uuid.UUID `db:"id" json:"id"` - CreatedAt time.Time `db:"created_at" json:"created_at"` - UpdatedAt time.Time `db:"updated_at" json:"updated_at"` - OwnerID uuid.UUID `db:"owner_id" json:"owner_id"` - OrganizationID uuid.UUID `db:"organization_id" json:"organization_id"` - TemplateID uuid.UUID `db:"template_id" json:"template_id"` - Deleted bool `db:"deleted" json:"deleted"` - Name string `db:"name" json:"name"` - AutostartSchedule sql.NullString `db:"autostart_schedule" json:"autostart_schedule"` - Ttl sql.NullInt64 `db:"ttl" json:"ttl"` - LastUsedAt time.Time `db:"last_used_at" json:"last_used_at"` - DormantAt sql.NullTime `db:"dormant_at" json:"dormant_at"` - DeletingAt sql.NullTime `db:"deleting_at" json:"deleting_at"` - AutomaticUpdates AutomaticUpdates `db:"automatic_updates" json:"automatic_updates"` - Favorite bool `db:"favorite" json:"favorite"` - NextStartAt sql.NullTime `db:"next_start_at" json:"next_start_at"` - GroupACL json.RawMessage `db:"group_acl" json:"group_acl"` - UserACL json.RawMessage `db:"user_acl" json:"user_acl"` - OwnerAvatarUrl string `db:"owner_avatar_url" json:"owner_avatar_url"` - OwnerUsername string `db:"owner_username" json:"owner_username"` - OwnerName string `db:"owner_name" json:"owner_name"` - OrganizationName string `db:"organization_name" json:"organization_name"` - OrganizationDisplayName string `db:"organization_display_name" json:"organization_display_name"` - OrganizationIcon string `db:"organization_icon" json:"organization_icon"` - OrganizationDescription string `db:"organization_description" json:"organization_description"` - TemplateName string `db:"template_name" json:"template_name"` - TemplateDisplayName string `db:"template_display_name" json:"template_display_name"` - TemplateIcon string `db:"template_icon" json:"template_icon"` - TemplateDescription string `db:"template_description" json:"template_description"` - TemplateVersionID uuid.UUID `db:"template_version_id" json:"template_version_id"` - TemplateVersionName sql.NullString `db:"template_version_name" json:"template_version_name"` - LatestBuildCompletedAt sql.NullTime `db:"latest_build_completed_at" json:"latest_build_completed_at"` - LatestBuildCanceledAt sql.NullTime `db:"latest_build_canceled_at" json:"latest_build_canceled_at"` - LatestBuildError sql.NullString `db:"latest_build_error" json:"latest_build_error"` - LatestBuildTransition WorkspaceTransition `db:"latest_build_transition" json:"latest_build_transition"` - LatestBuildStatus ProvisionerJobStatus `db:"latest_build_status" json:"latest_build_status"` - LatestBuildHasAITask sql.NullBool `db:"latest_build_has_ai_task" json:"latest_build_has_ai_task"` - Count int64 `db:"count" json:"count"` + ID uuid.UUID `db:"id" json:"id"` + CreatedAt time.Time `db:"created_at" json:"created_at"` + UpdatedAt time.Time `db:"updated_at" json:"updated_at"` + OwnerID uuid.UUID `db:"owner_id" json:"owner_id"` + OrganizationID uuid.UUID `db:"organization_id" json:"organization_id"` + TemplateID uuid.UUID `db:"template_id" json:"template_id"` + Deleted bool `db:"deleted" json:"deleted"` + Name string `db:"name" json:"name"` + AutostartSchedule sql.NullString `db:"autostart_schedule" json:"autostart_schedule"` + Ttl sql.NullInt64 `db:"ttl" json:"ttl"` + LastUsedAt time.Time `db:"last_used_at" json:"last_used_at"` + DormantAt sql.NullTime `db:"dormant_at" json:"dormant_at"` + DeletingAt sql.NullTime `db:"deleting_at" json:"deleting_at"` + AutomaticUpdates AutomaticUpdates `db:"automatic_updates" json:"automatic_updates"` + Favorite bool `db:"favorite" json:"favorite"` + NextStartAt sql.NullTime `db:"next_start_at" json:"next_start_at"` + GroupACL json.RawMessage `db:"group_acl" json:"group_acl"` + UserACL json.RawMessage `db:"user_acl" json:"user_acl"` + OwnerAvatarUrl string `db:"owner_avatar_url" json:"owner_avatar_url"` + OwnerUsername string `db:"owner_username" json:"owner_username"` + OwnerName string `db:"owner_name" json:"owner_name"` + OrganizationName string `db:"organization_name" json:"organization_name"` + OrganizationDisplayName string `db:"organization_display_name" json:"organization_display_name"` + OrganizationIcon string `db:"organization_icon" json:"organization_icon"` + OrganizationDescription string `db:"organization_description" json:"organization_description"` + TemplateName string `db:"template_name" json:"template_name"` + TemplateDisplayName string `db:"template_display_name" json:"template_display_name"` + TemplateIcon string `db:"template_icon" json:"template_icon"` + TemplateDescription string `db:"template_description" json:"template_description"` + TemplateVersionID uuid.UUID `db:"template_version_id" json:"template_version_id"` + TemplateVersionName sql.NullString `db:"template_version_name" json:"template_version_name"` + LatestBuildCompletedAt sql.NullTime `db:"latest_build_completed_at" json:"latest_build_completed_at"` + LatestBuildCanceledAt sql.NullTime `db:"latest_build_canceled_at" json:"latest_build_canceled_at"` + LatestBuildError sql.NullString `db:"latest_build_error" json:"latest_build_error"` + LatestBuildTransition WorkspaceTransition `db:"latest_build_transition" json:"latest_build_transition"` + LatestBuildStatus ProvisionerJobStatus `db:"latest_build_status" json:"latest_build_status"` + LatestBuildHasAITask sql.NullBool `db:"latest_build_has_ai_task" json:"latest_build_has_ai_task"` + LatestBuildHasExternalAgent sql.NullBool `db:"latest_build_has_external_agent" json:"latest_build_has_external_agent"` + Count int64 `db:"count" json:"count"` } // build_params is used to filter by build parameters if present. @@ -20383,6 +20460,7 @@ func (q *sqlQuerier) GetWorkspaces(ctx context.Context, arg GetWorkspacesParams) arg.LastUsedAfter, arg.UsingActive, arg.HasAITask, + arg.HasExternalAgent, arg.RequesterID, arg.Offset, arg.Limit, @@ -20433,6 +20511,7 @@ func (q *sqlQuerier) GetWorkspaces(ctx context.Context, arg GetWorkspacesParams) &i.LatestBuildTransition, &i.LatestBuildStatus, &i.LatestBuildHasAITask, + &i.LatestBuildHasExternalAgent, &i.Count, ); err != nil { return nil, err diff --git a/coderd/searchquery/search_test.go b/coderd/searchquery/search_test.go index 4744b57edff4a..8959d92401e82 100644 --- a/coderd/searchquery/search_test.go +++ b/coderd/searchquery/search_test.go @@ -252,6 +252,36 @@ func TestSearchWorkspace(t *testing.T) { }, }, }, + { + Name: "HasExternalAgentTrue", + Query: "has-external-agent:true", + Expected: database.GetWorkspacesParams{ + HasExternalAgent: sql.NullBool{ + Bool: true, + Valid: true, + }, + }, + }, + { + Name: "HasExternalAgentFalse", + Query: "has-external-agent:false", + Expected: database.GetWorkspacesParams{ + HasExternalAgent: sql.NullBool{ + Bool: false, + Valid: true, + }, + }, + }, + { + Name: "HasExternalAgentMissing", + Query: "", + Expected: database.GetWorkspacesParams{ + HasExternalAgent: sql.NullBool{ + Bool: false, + Valid: false, + }, + }, + }, // Failures { @@ -688,6 +718,36 @@ func TestSearchTemplates(t *testing.T) { }, }, }, + { + Name: "HasExternalAgent", + Query: "has-external-agent:true", + Expected: database.GetTemplatesWithFilterParams{ + HasExternalAgent: sql.NullBool{ + Bool: true, + Valid: true, + }, + }, + }, + { + Name: "HasExternalAgentFalse", + Query: "has-external-agent:false", + Expected: database.GetTemplatesWithFilterParams{ + HasExternalAgent: sql.NullBool{ + Bool: false, + Valid: true, + }, + }, + }, + { + Name: "HasExternalAgentMissing", + Query: "", + Expected: database.GetTemplatesWithFilterParams{ + HasExternalAgent: sql.NullBool{ + Bool: false, + Valid: false, + }, + }, + }, } for _, c := range testCases { diff --git a/coderd/templates_test.go b/coderd/templates_test.go index 0858ce83325cc..bbffbf68922c4 100644 --- a/coderd/templates_test.go +++ b/coderd/templates_test.go @@ -1875,3 +1875,59 @@ func TestTemplateFilterHasAITask(t *testing.T) { require.Contains(t, templates, templateWithAITask) require.Contains(t, templates, templateWithoutAITask) } + +func TestTemplateFilterHasExternalAgent(t *testing.T) { + t.Parallel() + + db, pubsub := dbtestutil.NewDB(t) + client := coderdtest.New(t, &coderdtest.Options{ + Database: db, + Pubsub: pubsub, + IncludeProvisionerDaemon: true, + }) + user := coderdtest.CreateFirstUser(t, client) + + jobWithExternalAgent := dbgen.ProvisionerJob(t, db, pubsub, database.ProvisionerJob{ + OrganizationID: user.OrganizationID, + InitiatorID: user.UserID, + Tags: database.StringMap{}, + Type: database.ProvisionerJobTypeTemplateVersionImport, + }) + jobWithoutExternalAgent := dbgen.ProvisionerJob(t, db, pubsub, database.ProvisionerJob{ + OrganizationID: user.OrganizationID, + InitiatorID: user.UserID, + Tags: database.StringMap{}, + Type: database.ProvisionerJobTypeTemplateVersionImport, + }) + versionWithExternalAgent := dbgen.TemplateVersion(t, db, database.TemplateVersion{ + OrganizationID: user.OrganizationID, + CreatedBy: user.UserID, + HasExternalAgent: sql.NullBool{Bool: true, Valid: true}, + JobID: jobWithExternalAgent.ID, + }) + versionWithoutExternalAgent := dbgen.TemplateVersion(t, db, database.TemplateVersion{ + OrganizationID: user.OrganizationID, + CreatedBy: user.UserID, + HasExternalAgent: sql.NullBool{Bool: false, Valid: true}, + JobID: jobWithoutExternalAgent.ID, + }) + templateWithExternalAgent := coderdtest.CreateTemplate(t, client, user.OrganizationID, versionWithExternalAgent.ID) + templateWithoutExternalAgent := coderdtest.CreateTemplate(t, client, user.OrganizationID, versionWithoutExternalAgent.ID) + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + templates, err := client.Templates(ctx, codersdk.TemplateFilter{ + SearchQuery: "has-external-agent:true", + }) + require.NoError(t, err) + require.Len(t, templates, 1) + require.Equal(t, templateWithExternalAgent.ID, templates[0].ID) + + templates, err = client.Templates(ctx, codersdk.TemplateFilter{ + SearchQuery: "has-external-agent:false", + }) + require.NoError(t, err) + require.Len(t, templates, 1) + require.Equal(t, templateWithoutExternalAgent.ID, templates[0].ID) +} diff --git a/docs/admin/security/audit-logs.md b/docs/admin/security/audit-logs.md index 0232c3d45a0c2..69d85b0d67f72 100644 --- a/docs/admin/security/audit-logs.md +++ b/docs/admin/security/audit-logs.md @@ -33,9 +33,9 @@ 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
cors_behaviortrue
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_agentfalse
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
| +| WorkspaceBuild
start, stop | |
FieldTracked
ai_task_sidebar_app_idfalse
build_numberfalse
created_atfalse
daily_costfalse
deadlinefalse
has_ai_taskfalse
has_external_agentfalse
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
| | WorkspaceTable
| |
FieldTracked
automatic_updatestrue
autostart_scheduletrue
created_atfalse
deletedfalse
deleting_attrue
dormant_attrue
favoritetrue
group_acltrue
idtrue
last_used_atfalse
nametrue
next_start_attrue
organization_idfalse
owner_idtrue
template_idtrue
ttltrue
updated_atfalse
user_acltrue
| diff --git a/docs/reference/cli/external-workspaces_create.md b/docs/reference/cli/external-workspaces_create.md index 8694b763540d4..b0744387a1d70 100644 --- a/docs/reference/cli/external-workspaces_create.md +++ b/docs/reference/cli/external-workspaces_create.md @@ -37,6 +37,15 @@ Specify a template name. Specify a template version name. +### --preset + +| | | +|-------------|---------------------------------| +| Type | string | +| Environment | $CODER_PRESET_NAME | + +Specify the name of a template version preset. Use 'none' to explicitly indicate that no preset should be used. + ### --start-at | | | diff --git a/provisioner/terraform/provision_test.go b/provisioner/terraform/provision_test.go index d067965997308..1aaa6da465308 100644 --- a/provisioner/terraform/provision_test.go +++ b/provisioner/terraform/provision_test.go @@ -1135,6 +1135,31 @@ func TestProvision(t *testing.T) { HasAiTasks: true, }, }, + { + Name: "external-agent", + Files: map[string]string{ + "main.tf": `terraform { + required_providers { + coder = { + source = "coder/coder" + version = ">= 2.7.0" + } + } + } + resource "coder_external_agent" "example" { + token = "123" + } + `, + }, + Response: &proto.PlanComplete{ + Resources: []*proto.Resource{{ + Name: "example", + Type: "coder_external_agent", + }}, + HasExternalAgents: true, + }, + SkipCacheProviders: true, + }, } // Remove unused cache dirs before running tests. @@ -1237,6 +1262,7 @@ func TestProvision(t *testing.T) { require.Equal(t, string(modulesWant), string(modulesGot)) require.Equal(t, planComplete.HasAiTasks, testCase.Response.HasAiTasks) + require.Equal(t, planComplete.HasExternalAgents, testCase.Response.HasExternalAgents) } if testCase.Apply { diff --git a/provisioner/terraform/resources_test.go b/provisioner/terraform/resources_test.go index 1575c6c9c159e..715055c00cad9 100644 --- a/provisioner/terraform/resources_test.go +++ b/provisioner/terraform/resources_test.go @@ -1573,6 +1573,35 @@ func TestAITasks(t *testing.T) { }) } +func TestExternalAgents(t *testing.T) { + t.Parallel() + ctx, logger := ctxAndLogger(t) + + t.Run("External agents can be defined", func(t *testing.T) { + t.Parallel() + + // nolint:dogsled + _, filename, _, _ := runtime.Caller(0) + + dir := filepath.Join(filepath.Dir(filename), "testdata", "resources", "external-agents") + tfPlanRaw, err := os.ReadFile(filepath.Join(dir, "external-agents.tfplan.json")) + require.NoError(t, err) + var tfPlan tfjson.Plan + err = json.Unmarshal(tfPlanRaw, &tfPlan) + require.NoError(t, err) + tfPlanGraph, err := os.ReadFile(filepath.Join(dir, "external-agents.tfplan.dot")) + require.NoError(t, err) + + state, err := terraform.ConvertState(ctx, []*tfjson.StateModule{tfPlan.PlannedValues.RootModule, tfPlan.PriorState.Values.RootModule}, string(tfPlanGraph), logger) + require.NotNil(t, state) + require.NoError(t, err) + require.True(t, state.HasExternalAgents) + require.Len(t, state.Resources, 1) + require.Len(t, state.Resources[0].Agents, 1) + require.Equal(t, "dev1", state.Resources[0].Agents[0].Name) + }) +} + // sortResource ensures resources appear in a consistent ordering // to prevent tests from flaking. func sortResources(resources []*proto.Resource) { diff --git a/provisioner/terraform/testdata/resources/external-agents/external-agents.tfplan.dot b/provisioner/terraform/testdata/resources/external-agents/external-agents.tfplan.dot new file mode 100644 index 0000000000000..d2db86a89e488 --- /dev/null +++ b/provisioner/terraform/testdata/resources/external-agents/external-agents.tfplan.dot @@ -0,0 +1,22 @@ +digraph { + compound = "true" + newrank = "true" + subgraph "root" { + "[root] coder_agent.dev1 (expand)" [label = "coder_agent.dev1", shape = "box"] + "[root] coder_external_agent.dev1 (expand)" [label = "coder_external_agent.dev1", shape = "box"] + "[root] data.coder_provisioner.me (expand)" [label = "data.coder_provisioner.me", shape = "box"] + "[root] data.coder_workspace.me (expand)" [label = "data.coder_workspace.me", shape = "box"] + "[root] data.coder_workspace_owner.me (expand)" [label = "data.coder_workspace_owner.me", shape = "box"] + "[root] provider[\"registry.terraform.io/coder/coder\"]" [label = "provider[\"registry.terraform.io/coder/coder\"]", shape = "diamond"] + "[root] coder_agent.dev1 (expand)" -> "[root] provider[\"registry.terraform.io/coder/coder\"]" + "[root] coder_external_agent.dev1 (expand)" -> "[root] coder_agent.dev1 (expand)" + "[root] data.coder_provisioner.me (expand)" -> "[root] provider[\"registry.terraform.io/coder/coder\"]" + "[root] data.coder_workspace.me (expand)" -> "[root] provider[\"registry.terraform.io/coder/coder\"]" + "[root] data.coder_workspace_owner.me (expand)" -> "[root] provider[\"registry.terraform.io/coder/coder\"]" + "[root] provider[\"registry.terraform.io/coder/coder\"] (close)" -> "[root] coder_external_agent.dev1 (expand)" + "[root] provider[\"registry.terraform.io/coder/coder\"] (close)" -> "[root] data.coder_provisioner.me (expand)" + "[root] provider[\"registry.terraform.io/coder/coder\"] (close)" -> "[root] data.coder_workspace.me (expand)" + "[root] provider[\"registry.terraform.io/coder/coder\"] (close)" -> "[root] data.coder_workspace_owner.me (expand)" + "[root] root" -> "[root] provider[\"registry.terraform.io/coder/coder\"] (close)" + } +} diff --git a/provisioner/terraform/testdata/resources/external-agents/external-agents.tfplan.json b/provisioner/terraform/testdata/resources/external-agents/external-agents.tfplan.json new file mode 100644 index 0000000000000..317ef993211cb --- /dev/null +++ b/provisioner/terraform/testdata/resources/external-agents/external-agents.tfplan.json @@ -0,0 +1,277 @@ +{ + "format_version": "1.2", + "terraform_version": "1.12.2", + "planned_values": { + "root_module": { + "resources": [ + { + "address": "coder_agent.dev1", + "mode": "managed", + "type": "coder_agent", + "name": "dev1", + "provider_name": "registry.terraform.io/coder/coder", + "schema_version": 1, + "values": { + "api_key_scope": "all", + "arch": "amd64", + "auth": "token", + "connection_timeout": 120, + "dir": null, + "env": null, + "metadata": [], + "motd_file": null, + "order": null, + "os": "linux", + "resources_monitoring": [], + "shutdown_script": null, + "startup_script": null, + "startup_script_behavior": "non-blocking", + "troubleshooting_url": null + }, + "sensitive_values": { + "display_apps": [], + "metadata": [], + "resources_monitoring": [], + "token": true + } + }, + { + "address": "coder_external_agent.dev1", + "mode": "managed", + "type": "coder_external_agent", + "name": "dev1", + "provider_name": "registry.terraform.io/coder/coder", + "schema_version": 1, + "sensitive_values": { + "token": true + } + } + ] + } + }, + "resource_changes": [ + { + "address": "coder_agent.dev1", + "mode": "managed", + "type": "coder_agent", + "name": "dev1", + "provider_name": "registry.terraform.io/coder/coder", + "change": { + "actions": [ + "create" + ], + "before": null, + "after": { + "api_key_scope": "all", + "arch": "amd64", + "auth": "token", + "connection_timeout": 120, + "dir": null, + "env": null, + "metadata": [], + "motd_file": null, + "order": null, + "os": "linux", + "resources_monitoring": [], + "shutdown_script": null, + "startup_script": null, + "startup_script_behavior": "non-blocking", + "troubleshooting_url": null + }, + "after_unknown": { + "display_apps": true, + "id": true, + "init_script": true, + "metadata": [], + "resources_monitoring": [], + "token": true + }, + "before_sensitive": false, + "after_sensitive": { + "display_apps": [], + "metadata": [], + "resources_monitoring": [], + "token": true + } + } + }, + { + "address": "coder_external_agent.dev1", + "mode": "managed", + "type": "coder_external_agent", + "name": "dev1", + "provider_name": "registry.terraform.io/coder/coder", + "change": { + "actions": [ + "create" + ], + "before": null, + "after": {}, + "after_unknown": { + "id": true, + "token": true + }, + "before_sensitive": false, + "after_sensitive": { + "token": true + } + } + } + ], + "prior_state": { + "format_version": "1.0", + "terraform_version": "1.12.2", + "values": { + "root_module": { + "resources": [ + { + "address": "data.coder_provisioner.me", + "mode": "data", + "type": "coder_provisioner", + "name": "me", + "provider_name": "registry.terraform.io/coder/coder", + "schema_version": 1, + "values": { + "arch": "amd64", + "id": "d607be41-7697-475f-8257-2f6e24adbede", + "os": "linux" + }, + "sensitive_values": {} + }, + { + "address": "data.coder_workspace.me", + "mode": "data", + "type": "coder_workspace", + "name": "me", + "provider_name": "registry.terraform.io/coder/coder", + "schema_version": 1, + "values": { + "access_port": 443, + "access_url": "https://dev.coder.com/", + "id": "0b7fc772-5e27-4096-b8a3-9e6a8b914ebe", + "is_prebuild": false, + "is_prebuild_claim": false, + "name": "kacper", + "prebuild_count": 0, + "start_count": 1, + "template_id": "", + "template_name": "", + "template_version": "", + "transition": "start" + }, + "sensitive_values": {} + }, + { + "address": "data.coder_workspace_owner.me", + "mode": "data", + "type": "coder_workspace_owner", + "name": "me", + "provider_name": "registry.terraform.io/coder/coder", + "schema_version": 0, + "values": { + "email": "default@example.com", + "full_name": "kacpersaw", + "groups": [], + "id": "1ebd1795-7cf2-47c5-8024-5d56e68f1681", + "login_type": null, + "name": "default", + "oidc_access_token": "", + "rbac_roles": [], + "session_token": "", + "ssh_private_key": "", + "ssh_public_key": "" + }, + "sensitive_values": { + "groups": [], + "oidc_access_token": true, + "rbac_roles": [], + "session_token": true, + "ssh_private_key": true + } + } + ] + } + } + }, + "configuration": { + "provider_config": { + "coder": { + "name": "coder", + "full_name": "registry.terraform.io/coder/coder", + "version_constraint": ">= 2.0.0" + } + }, + "root_module": { + "resources": [ + { + "address": "coder_agent.dev1", + "mode": "managed", + "type": "coder_agent", + "name": "dev1", + "provider_config_key": "coder", + "expressions": { + "arch": { + "constant_value": "amd64" + }, + "os": { + "constant_value": "linux" + } + }, + "schema_version": 1 + }, + { + "address": "coder_external_agent.dev1", + "mode": "managed", + "type": "coder_external_agent", + "name": "dev1", + "provider_config_key": "coder", + "expressions": { + "token": { + "references": [ + "coder_agent.dev1.token", + "coder_agent.dev1" + ] + } + }, + "schema_version": 1 + }, + { + "address": "data.coder_provisioner.me", + "mode": "data", + "type": "coder_provisioner", + "name": "me", + "provider_config_key": "coder", + "schema_version": 1 + }, + { + "address": "data.coder_workspace.me", + "mode": "data", + "type": "coder_workspace", + "name": "me", + "provider_config_key": "coder", + "schema_version": 1 + }, + { + "address": "data.coder_workspace_owner.me", + "mode": "data", + "type": "coder_workspace_owner", + "name": "me", + "provider_config_key": "coder", + "schema_version": 0 + } + ] + } + }, + "relevant_attributes": [ + { + "resource": "coder_agent.dev1", + "attribute": [ + "token" + ] + } + ], + "timestamp": "2025-07-31T11:08:54Z", + "applyable": true, + "complete": true, + "errored": false +} diff --git a/provisioner/terraform/testdata/resources/external-agents/external-agents.tfstate.dot b/provisioner/terraform/testdata/resources/external-agents/external-agents.tfstate.dot new file mode 100644 index 0000000000000..d2db86a89e488 --- /dev/null +++ b/provisioner/terraform/testdata/resources/external-agents/external-agents.tfstate.dot @@ -0,0 +1,22 @@ +digraph { + compound = "true" + newrank = "true" + subgraph "root" { + "[root] coder_agent.dev1 (expand)" [label = "coder_agent.dev1", shape = "box"] + "[root] coder_external_agent.dev1 (expand)" [label = "coder_external_agent.dev1", shape = "box"] + "[root] data.coder_provisioner.me (expand)" [label = "data.coder_provisioner.me", shape = "box"] + "[root] data.coder_workspace.me (expand)" [label = "data.coder_workspace.me", shape = "box"] + "[root] data.coder_workspace_owner.me (expand)" [label = "data.coder_workspace_owner.me", shape = "box"] + "[root] provider[\"registry.terraform.io/coder/coder\"]" [label = "provider[\"registry.terraform.io/coder/coder\"]", shape = "diamond"] + "[root] coder_agent.dev1 (expand)" -> "[root] provider[\"registry.terraform.io/coder/coder\"]" + "[root] coder_external_agent.dev1 (expand)" -> "[root] coder_agent.dev1 (expand)" + "[root] data.coder_provisioner.me (expand)" -> "[root] provider[\"registry.terraform.io/coder/coder\"]" + "[root] data.coder_workspace.me (expand)" -> "[root] provider[\"registry.terraform.io/coder/coder\"]" + "[root] data.coder_workspace_owner.me (expand)" -> "[root] provider[\"registry.terraform.io/coder/coder\"]" + "[root] provider[\"registry.terraform.io/coder/coder\"] (close)" -> "[root] coder_external_agent.dev1 (expand)" + "[root] provider[\"registry.terraform.io/coder/coder\"] (close)" -> "[root] data.coder_provisioner.me (expand)" + "[root] provider[\"registry.terraform.io/coder/coder\"] (close)" -> "[root] data.coder_workspace.me (expand)" + "[root] provider[\"registry.terraform.io/coder/coder\"] (close)" -> "[root] data.coder_workspace_owner.me (expand)" + "[root] root" -> "[root] provider[\"registry.terraform.io/coder/coder\"] (close)" + } +} diff --git a/provisioner/terraform/testdata/resources/external-agents/external-agents.tfstate.json b/provisioner/terraform/testdata/resources/external-agents/external-agents.tfstate.json new file mode 100644 index 0000000000000..807508201ce15 --- /dev/null +++ b/provisioner/terraform/testdata/resources/external-agents/external-agents.tfstate.json @@ -0,0 +1,138 @@ +{ + "format_version": "1.0", + "terraform_version": "1.12.2", + "values": { + "root_module": { + "resources": [ + { + "address": "data.coder_provisioner.me", + "mode": "data", + "type": "coder_provisioner", + "name": "me", + "provider_name": "registry.terraform.io/coder/coder", + "schema_version": 1, + "values": { + "arch": "amd64", + "id": "0ce4713c-28d6-4999-9381-52b8a603b672", + "os": "linux" + }, + "sensitive_values": {} + }, + { + "address": "data.coder_workspace.me", + "mode": "data", + "type": "coder_workspace", + "name": "me", + "provider_name": "registry.terraform.io/coder/coder", + "schema_version": 1, + "values": { + "access_port": 443, + "access_url": "https://dev.coder.com/", + "id": "dfa1dbe8-ad31-410b-b201-a4ed4d884938", + "is_prebuild": false, + "is_prebuild_claim": false, + "name": "kacper", + "prebuild_count": 0, + "start_count": 1, + "template_id": "", + "template_name": "", + "template_version": "", + "transition": "start" + }, + "sensitive_values": {} + }, + { + "address": "data.coder_workspace_owner.me", + "mode": "data", + "type": "coder_workspace_owner", + "name": "me", + "provider_name": "registry.terraform.io/coder/coder", + "schema_version": 0, + "values": { + "email": "default@example.com", + "full_name": "kacpersaw", + "groups": [], + "id": "f5e82b90-ea22-4288-8286-9cf7af651143", + "login_type": null, + "name": "default", + "oidc_access_token": "", + "rbac_roles": [], + "session_token": "", + "ssh_private_key": "", + "ssh_public_key": "" + }, + "sensitive_values": { + "groups": [], + "oidc_access_token": true, + "rbac_roles": [], + "session_token": true, + "ssh_private_key": true + } + }, + { + "address": "coder_agent.dev1", + "mode": "managed", + "type": "coder_agent", + "name": "dev1", + "provider_name": "registry.terraform.io/coder/coder", + "schema_version": 1, + "values": { + "api_key_scope": "all", + "arch": "amd64", + "auth": "token", + "connection_timeout": 120, + "dir": null, + "display_apps": [ + { + "port_forwarding_helper": true, + "ssh_helper": true, + "vscode": true, + "vscode_insiders": false, + "web_terminal": true + } + ], + "env": null, + "id": "15a35370-3b2e-4ee7-8b28-81cef0152d8b", + "init_script": "", + "metadata": [], + "motd_file": null, + "order": null, + "os": "linux", + "resources_monitoring": [], + "shutdown_script": null, + "startup_script": null, + "startup_script_behavior": "non-blocking", + "token": "d054c66b-cc5c-41ae-aa0c-2098a1075272", + "troubleshooting_url": null + }, + "sensitive_values": { + "display_apps": [ + {} + ], + "metadata": [], + "resources_monitoring": [], + "token": true + } + }, + { + "address": "coder_external_agent.dev1", + "mode": "managed", + "type": "coder_external_agent", + "name": "dev1", + "provider_name": "registry.terraform.io/coder/coder", + "schema_version": 1, + "values": { + "id": "4d87dd70-879c-4347-b0c1-b8f3587d1021", + "token": "d054c66b-cc5c-41ae-aa0c-2098a1075272" + }, + "sensitive_values": { + "token": true + }, + "depends_on": [ + "coder_agent.dev1" + ] + } + ] + } + } +} diff --git a/provisioner/terraform/testdata/resources/external-agents/main.tf b/provisioner/terraform/testdata/resources/external-agents/main.tf new file mode 100644 index 0000000000000..6e00b81a80dfc --- /dev/null +++ b/provisioner/terraform/testdata/resources/external-agents/main.tf @@ -0,0 +1,21 @@ +terraform { + required_providers { + coder = { + source = "coder/coder" + version = ">=2.0.0" + } + } +} + +data "coder_provisioner" "me" {} +data "coder_workspace" "me" {} +data "coder_workspace_owner" "me" {} + +resource "coder_agent" "dev1" { + os = "linux" + arch = "amd64" +} + +resource "coder_external_agent" "dev1" { + token = coder_agent.dev1.token +} diff --git a/provisioner/terraform/testdata/resources/version.txt b/provisioner/terraform/testdata/resources/version.txt index 3d0e62313ced1..6b89d58f861a7 100644 --- a/provisioner/terraform/testdata/resources/version.txt +++ b/provisioner/terraform/testdata/resources/version.txt @@ -1 +1 @@ -1.11.4 +1.12.2 diff --git a/provisionersdk/proto/provisioner.pb.go b/provisionersdk/proto/provisioner.pb.go index 52d40ef87dd4d..c96878fba5fea 100644 --- a/provisionersdk/proto/provisioner.pb.go +++ b/provisionersdk/proto/provisioner.pb.go @@ -3401,8 +3401,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() { @@ -3528,6 +3529,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 { @@ -4855,7 +4863,7 @@ var file_provisionersdk_proto_provisioner_proto_rawDesc = []byte{ 0x61, 0x6d, 0x65, 0x74, 0x65, 0x72, 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, + 0x75, 0x6c, 0x65, 0x46, 0x69, 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, @@ -4896,7 +4904,10 @@ var file_provisionersdk_proto_provisioner_proto_rawDesc = []byte{ 0x52, 0x0a, 0x68, 0x61, 0x73, 0x41, 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, + 0x61, 0x73, 0x6b, 0x52, 0x07, 0x61, 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, From e281f0ec6364d5479954025b4ad382439011a310 Mon Sep 17 00:00:00 2001 From: Kacper Sawicki Date: Tue, 5 Aug 2025 09:37:13 +0000 Subject: [PATCH 08/28] Delete coder attach golden --- cli/testdata/coder_attach_--help.golden | 38 ------------------------- 1 file changed, 38 deletions(-) delete mode 100644 cli/testdata/coder_attach_--help.golden diff --git a/cli/testdata/coder_attach_--help.golden b/cli/testdata/coder_attach_--help.golden deleted file mode 100644 index 9d2c98fa3c3e8..0000000000000 --- a/cli/testdata/coder_attach_--help.golden +++ /dev/null @@ -1,38 +0,0 @@ -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. From d77522db758ee05a43bb29afe6ec88c5d4973648 Mon Sep 17 00:00:00 2001 From: Kacper Sawicki Date: Tue, 5 Aug 2025 11:43:01 +0000 Subject: [PATCH 09/28] Hide agent apps when connecting & is external agent --- site/src/modules/resources/AgentRow.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/site/src/modules/resources/AgentRow.tsx b/site/src/modules/resources/AgentRow.tsx index 6f10a5e96a801..ac72120669761 100644 --- a/site/src/modules/resources/AgentRow.tsx +++ b/site/src/modules/resources/AgentRow.tsx @@ -66,7 +66,7 @@ export const AgentRow: FC = ({ const isExternalAgent = workspace.latest_build.has_external_agent; const shouldDisplayAgentApps = (agent.status === "connected" && hasAppsToDisplay) || - agent.status === "connecting"; + (agent.status === "connecting" && !isExternalAgent); const hasVSCodeApp = agent.display_apps.includes("vscode") || agent.display_apps.includes("vscode_insiders"); From f9274fe41eabb485e9aa76ed6f3505a4e27de124 Mon Sep 17 00:00:00 2001 From: Kacper Sawicki Date: Tue, 5 Aug 2025 11:48:38 +0000 Subject: [PATCH 10/28] Reformat code --- .../components/CodeExample/CodeExample.tsx | 23 ++++++++--- .../resources/AgentExternal.stories.tsx | 9 +---- site/src/modules/resources/AgentExternal.tsx | 38 +++++++++++-------- site/src/modules/resources/AgentRow.tsx | 16 +++++--- 4 files changed, 52 insertions(+), 34 deletions(-) diff --git a/site/src/components/CodeExample/CodeExample.tsx b/site/src/components/CodeExample/CodeExample.tsx index b3b193b158c81..8c24142b727c4 100644 --- a/site/src/components/CodeExample/CodeExample.tsx +++ b/site/src/components/CodeExample/CodeExample.tsx @@ -1,10 +1,15 @@ import type { Interpolation, Theme } from "@emotion/react"; -import { useState, type FC } from "react"; -import { MONOSPACE_FONT_FAMILY } from "theme/constants"; -import { CopyButton } from "../CopyButton/CopyButton"; -import { TooltipContent, TooltipTrigger, TooltipProvider, Tooltip } from "components/Tooltip/Tooltip"; import { Button } from "components/Button/Button"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "components/Tooltip/Tooltip"; import { EyeIcon, EyeOffIcon } from "lucide-react"; +import { type FC, useState } from "react"; +import { MONOSPACE_FONT_FAMILY } from "theme/constants"; +import { CopyButton } from "../CopyButton/CopyButton"; interface CodeExampleProps { code: string; @@ -41,8 +46,14 @@ export const CodeExample: FC = ({ ? code.replace(redactPattern, redactReplacement) : code; - const showButtonLabel = showFullValue ? "Hide sensitive data" : "Show sensitive data"; - const icon = showFullValue ? : ; + const showButtonLabel = showFullValue + ? "Hide sensitive data" + : "Show sensitive data"; + const icon = showFullValue ? ( + + ) : ( + + ); return (
diff --git a/site/src/modules/resources/AgentExternal.stories.tsx b/site/src/modules/resources/AgentExternal.stories.tsx index 70772344e8761..ed49a10efeee6 100644 --- a/site/src/modules/resources/AgentExternal.stories.tsx +++ b/site/src/modules/resources/AgentExternal.stories.tsx @@ -1,12 +1,7 @@ import type { Meta, StoryObj } from "@storybook/react"; import { chromatic } from "testHelpers/chromatic"; -import { - MockWorkspace, - MockWorkspaceAgent, -} from "testHelpers/entities"; -import { - withDashboardProvider, -} from "testHelpers/storybook"; +import { MockWorkspace, MockWorkspaceAgent } from "testHelpers/entities"; +import { withDashboardProvider } from "testHelpers/storybook"; import { AgentExternal } from "./AgentExternal"; const meta: Meta = { diff --git a/site/src/modules/resources/AgentExternal.tsx b/site/src/modules/resources/AgentExternal.tsx index e5229edb9e73d..277b2b2b3f0a0 100644 --- a/site/src/modules/resources/AgentExternal.tsx +++ b/site/src/modules/resources/AgentExternal.tsx @@ -3,7 +3,7 @@ import { API } from "api/api"; import type { Workspace, WorkspaceAgent } from "api/typesGenerated"; import isChromatic from "chromatic/isChromatic"; import { CodeExample } from "components/CodeExample/CodeExample"; -import { useEffect, useState, type FC } from "react"; +import { type FC, useEffect, useState } from "react"; interface AgentExternalProps { isExternalAgent: boolean; @@ -16,7 +16,9 @@ export const AgentExternal: FC = ({ agent, workspace, }) => { - const [externalAgentToken, setExternalAgentToken] = useState(null); + const [externalAgentToken, setExternalAgentToken] = useState( + null, + ); const origin = isChromatic() ? "https://example.com" : window.location.origin; let initScriptURL = `${origin}/api/v2/init-script`; @@ -25,25 +27,31 @@ export const AgentExternal: FC = ({ } useEffect(() => { - if (isExternalAgent && (agent.status === "timeout" || agent.status === "connecting")) { + if ( + isExternalAgent && + (agent.status === "timeout" || agent.status === "connecting") + ) { API.getWorkspaceAgentCredentials(workspace.id, agent.name).then((res) => { setExternalAgentToken(res.agent_token); }); } }, [isExternalAgent, agent.status, workspace.id, agent.name]); - return
-

- Please run the following command to attach an agent to the {workspace.name} workspace: -

- -
; + return ( +
+

+ Please run the following command to attach an agent to the{" "} + {workspace.name} workspace: +

+ +
+ ); }; const styles = { diff --git a/site/src/modules/resources/AgentRow.tsx b/site/src/modules/resources/AgentRow.tsx index ac72120669761..31821af5bbce2 100644 --- a/site/src/modules/resources/AgentRow.tsx +++ b/site/src/modules/resources/AgentRow.tsx @@ -27,6 +27,7 @@ import AutoSizer from "react-virtualized-auto-sizer"; import type { FixedSizeList as List, ListOnScrollProps } from "react-window"; import { AgentApps, organizeAgentApps } from "./AgentApps/AgentApps"; import { AgentDevcontainerCard } from "./AgentDevcontainerCard"; +import { AgentExternal } from "./AgentExternal"; import { AgentLatency } from "./AgentLatency"; import { AGENT_LOG_LINE_HEIGHT } from "./AgentLogs/AgentLogLine"; import { AgentLogs } from "./AgentLogs/AgentLogs"; @@ -40,7 +41,6 @@ import { TerminalLink } from "./TerminalLink/TerminalLink"; import { VSCodeDesktopButton } from "./VSCodeDesktopButton/VSCodeDesktopButton"; import { useAgentContainers } from "./useAgentContainers"; import { useAgentLogs } from "./useAgentLogs"; -import { AgentExternal } from "./AgentExternal"; interface AgentRowProps { agent: WorkspaceAgent; @@ -76,7 +76,7 @@ export const AgentRow: FC = ({ const { proxy } = useProxy(); const [showLogs, setShowLogs] = useState( ["starting", "start_timeout"].includes(agent.lifecycle_state) && - hasStartupFeatures, + hasStartupFeatures, ); const agentLogs = useAgentLogs(agent, showLogs); const logListRef = useRef(null); @@ -296,10 +296,14 @@ export const AgentRow: FC = ({ )} - - {isExternalAgent && (agent.status === "timeout" || agent.status === "connecting") && ( - - )} + {isExternalAgent && + (agent.status === "timeout" || agent.status === "connecting") && ( + + )}
From c019a3166dec0908641f5951edd1bf554f72d755 Mon Sep 17 00:00:00 2001 From: Kacper Sawicki Date: Wed, 6 Aug 2025 09:00:43 +0000 Subject: [PATCH 11/28] bump provisionerd proto version to v1.9 --- provisionerd/proto/version.go | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/provisionerd/proto/version.go b/provisionerd/proto/version.go index 10e73a3be176c..3ae1bbae04454 100644 --- a/provisionerd/proto/version.go +++ b/provisionerd/proto/version.go @@ -47,9 +47,12 @@ import "github.com/coder/coder/v2/apiversion" // // API v1.8: // - Add new fields `description` and `icon` to `Preset`. +// +// API v1.9: +// - Added new field named 'has_external_agent' in 'CompleteJob.TemplateImport' const ( CurrentMajor = 1 - CurrentMinor = 8 + CurrentMinor = 9 ) // CurrentVersion is the current provisionerd API version. From 7d078573c5782e19736b477843472f6902d01730 Mon Sep 17 00:00:00 2001 From: Kacper Sawicki Date: Wed, 6 Aug 2025 13:09:14 +0000 Subject: [PATCH 12/28] Add beforeCreate and afterCreate to create handler, apply review suggestions --- cli/create.go | 22 +- cli/external_workspaces.go | 188 +++++++----------- cli/external_workspaces_test.go | 12 +- cli/root.go | 4 +- cli/testdata/coder_--help.golden | 2 +- .../coder_external-workspaces_--help.golden | 2 +- ...orkspaces_agent-instructions_--help.golden | 3 +- ...oder_provisioner_list_--output_json.golden | 2 +- docs/manifest.json | 2 +- docs/reference/cli/external-workspaces.md | 2 +- .../external-workspaces_agent-instructions.md | 2 +- docs/reference/cli/index.md | 2 +- 12 files changed, 106 insertions(+), 137 deletions(-) diff --git a/cli/create.go b/cli/create.go index 3f52e59e8ad90..db8253ddb5d03 100644 --- a/cli/create.go +++ b/cli/create.go @@ -29,7 +29,12 @@ const PresetNone = "none" var ErrNoPresetFound = xerrors.New("no preset found") -func (r *RootCmd) create() *serpent.Command { +type createOptions struct { + beforeCreate func(ctx context.Context, client *codersdk.Client, template codersdk.Template, templateVersionID uuid.UUID) error + afterCreate func(ctx context.Context, inv *serpent.Invocation, client *codersdk.Client, workspace codersdk.Workspace) error +} + +func (r *RootCmd) create(opts createOptions) *serpent.Command { var ( templateName string templateVersion string @@ -305,6 +310,13 @@ func (r *RootCmd) create() *serpent.Command { _, _ = fmt.Fprintf(inv.Stdout, "%s", cliui.Bold("No preset applied.")) } + if opts.beforeCreate != nil { + err = opts.beforeCreate(inv.Context(), client, template, templateVersionID) + if err != nil { + return xerrors.Errorf("before create: %w", err) + } + } + richParameters, err := prepWorkspaceBuild(inv, client, prepWorkspaceBuildArgs{ Action: WorkspaceCreate, TemplateVersionID: templateVersionID, @@ -366,6 +378,14 @@ func (r *RootCmd) create() *serpent.Command { cliui.Keyword(workspace.Name), cliui.Timestamp(time.Now()), ) + + if opts.afterCreate != nil { + err = opts.afterCreate(inv.Context(), inv, client, workspace) + if err != nil { + return err + } + } + return nil }, } diff --git a/cli/external_workspaces.go b/cli/external_workspaces.go index ce9f15243095f..cd5d9bec0f207 100644 --- a/cli/external_workspaces.go +++ b/cli/external_workspaces.go @@ -1,6 +1,7 @@ package cli import ( + "context" "fmt" "strings" @@ -15,10 +16,11 @@ import ( ) type externalAgent struct { - AgentName string `json:"-"` - AuthType string `json:"auth_type"` - AuthToken string `json:"auth_token"` - InitScript string `json:"init_script"` + WorkspaceName string `json:"-"` + AgentName string `json:"-"` + AuthType string `json:"auth_type"` + AuthToken string `json:"auth_token"` + InitScript string `json:"init_script"` } func (r *RootCmd) externalWorkspaces() *serpent.Command { @@ -26,7 +28,7 @@ func (r *RootCmd) externalWorkspaces() *serpent.Command { cmd := &serpent.Command{ Use: "external-workspaces [subcommand]", - Short: "External workspace related commands", + Short: "Create or manage external workspaces", Handler: func(inv *serpent.Invocation) error { return inv.Command.HelpHandler(inv) }, @@ -43,88 +45,61 @@ func (r *RootCmd) externalWorkspaces() *serpent.Command { // 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=") - } + opts := createOptions{ + beforeCreate: func(ctx context.Context, client *codersdk.Client, _ codersdk.Template, templateVersionID uuid.UUID) error { + resources, err := client.TemplateVersionResources(ctx, templateVersionID) + if err != nil { + return xerrors.Errorf("get template version resources: %w", err) + } + if len(resources) == 0 { + return xerrors.Errorf("no resources found for template version %q", templateVersionID) + } - organization, err := orgContext.Selected(inv, client) - if err != nil { - return xerrors.Errorf("get current organization: %w", err) - } + var hasExternalAgent bool + for _, resource := range resources { + if resource.Type == "coder_external_agent" { + hasExternalAgent = true + break + } + } - template, err := client.TemplateByName(inv.Context(), organization.ID, templateName.Value.String()) - if err != nil { - return xerrors.Errorf("get template by name: %w", err) - } + if !hasExternalAgent { + return xerrors.Errorf("template version %q does not have an external agent. Only templates with external agents can be used for external workspace creation", templateVersionID) + } - 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()) + return nil + }, + afterCreate: func(ctx context.Context, inv *serpent.Invocation, client *codersdk.Client, workspace codersdk.Workspace) error { + workspace, err := client.WorkspaceByOwnerAndName(ctx, codersdk.Me, workspace.Name, codersdk.WorkspaceOptions{}) if err != nil { - return xerrors.Errorf("get template version by name: %w", err) + return xerrors.Errorf("get workspace 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 + externalAgents, err := fetchExternalAgents(inv, client, workspace, workspace.LatestBuild.Resources) + if err != nil { + return xerrors.Errorf("fetch external agents: %w", err) } - } - 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 { + formatted := formatExternalAgent(workspace.Name, externalAgents) + _, err = fmt.Fprintln(inv.Stdout, formatted) 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) - } + cmd := r.create(opts) + cmd.Use = "create [workspace]" + cmd.Short = "Create a new external workspace" + cmd.Middleware = serpent.Chain( + cmd.Middleware, + serpent.RequireNArgs(1), + ) - externalAgents, err := fetchExternalAgents(inv, client, workspace, workspace.LatestBuild.Resources) - if err != nil { - return xerrors.Errorf("fetch external agents: %w", err) + for i := range cmd.Options { + if cmd.Options[i].Flag == "template" { + cmd.Options[i].Required = true } - - return printExternalAgents(inv, workspace.Name, externalAgents) } + return cmd } @@ -138,57 +113,37 @@ func (r *RootCmd) externalWorkspaceAgentInstructions() *serpent.Command { 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 + return formatExternalAgent(agent.WorkspaceName, []externalAgent{agent}), nil }), cliui.JSONFormat(), ) cmd := &serpent.Command{ - Use: "agent-instructions [workspace name] [agent name]", + Use: "agent-instructions [user/]workspace[.agent]", Short: "Get the instructions for an external agent", - Middleware: serpent.Chain(r.InitClient(client), serpent.RequireNArgs(2)), + Middleware: serpent.Chain(r.InitClient(client), serpent.RequireNArgs(1)), 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{}) + workspace, workspaceAgent, _, err := getWorkspaceAndAgent(inv.Context(), inv, client, false, inv.Args[0]) if err != nil { - return xerrors.Errorf("get workspace by name: %w", err) + return xerrors.Errorf("find workspace and agent: %w", err) } - credential, err := client.WorkspaceExternalAgentCredential(inv.Context(), workspace.ID, agentName) + credential, err := client.WorkspaceExternalAgentCredential(inv.Context(), workspace.ID, workspaceAgent.Name) 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 - } + return xerrors.Errorf("get external agent token for agent %q: %w", workspaceAgent.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) + if workspaceAgent.OperatingSystem != "linux" || workspaceAgent.Architecture != "amd64" { + initScriptURL = fmt.Sprintf("%s/api/v2/init-script?os=%s&arch=%s", client.URL, workspaceAgent.OperatingSystem, workspaceAgent.Architecture) } agentInfo := externalAgent{ - AgentName: agentName, - AuthType: "token", - AuthToken: credential.AgentToken, - InitScript: initScriptURL, + WorkspaceName: workspace.Name, + AgentName: workspaceAgent.Name, + AuthType: "token", + AuthToken: credential.AgentToken, + InitScript: initScriptURL, } out, err := formatter.Format(inv.Context(), agentInfo) @@ -305,22 +260,23 @@ func fetchExternalAgents(inv *serpent.Invocation, client *codersdk.Client, works 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)) +// formatExternalAgent formats the instructions for an external agent. +func formatExternalAgent(workspaceName string, externalAgents []externalAgent) string { + var output strings.Builder + _, _ = output.WriteString(fmt.Sprintf("\nPlease run the following commands to attach external agent to the workspace %s:\n\n", cliui.Keyword(workspaceName))) for i, agent := range externalAgents { if len(externalAgents) > 1 { - _, _ = fmt.Fprintf(inv.Stdout, "For agent %s:\n", cliui.Keyword(agent.AgentName)) + _, _ = output.WriteString(fmt.Sprintf("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))) + _, _ = output.WriteString(fmt.Sprintf("%s\n", pretty.Sprint(cliui.DefaultStyles.Code, fmt.Sprintf("export CODER_AGENT_TOKEN=%s", agent.AuthToken)))) + _, _ = output.WriteString(fmt.Sprintf("%s\n", pretty.Sprint(cliui.DefaultStyles.Code, fmt.Sprintf("curl -fsSL %s | sh", agent.InitScript)))) if i < len(externalAgents)-1 { - _, _ = fmt.Fprintf(inv.Stdout, "\n") + _, _ = output.WriteString("\n") } } - return nil + return output.String() } diff --git a/cli/external_workspaces_test.go b/cli/external_workspaces_test.go index f437f8b2281ab..4b758de7a4fb4 100644 --- a/cli/external_workspaces_test.go +++ b/cli/external_workspaces_test.go @@ -179,7 +179,7 @@ func TestExternalWorkspaces(t *testing.T) { err := inv.Run() require.Error(t, err) - assert.Contains(t, err.Error(), "template name is required for external workspace creation") + assert.Contains(t, err.Error(), "Missing values for the required flags: template") }) t.Run("CreateWithRegularTemplate", func(t *testing.T) { @@ -320,7 +320,6 @@ func TestExternalWorkspaces(t *testing.T) { "external-workspaces", "agent-instructions", ws.Name, - "external-agent", } inv, root := clitest.New(t, args...) clitest.SetupConfig(t, member, root) @@ -334,7 +333,7 @@ func TestExternalWorkspaces(t *testing.T) { assert.NoError(t, errC) close(done) }() - pty.ExpectMatch("Please run the following commands to attach agent external-agent:") + pty.ExpectMatch("Please run the following commands to attach external agent to the workspace") pty.ExpectMatch("export CODER_AGENT_TOKEN=") pty.ExpectMatch("curl -fsSL") cancelFunc() @@ -358,7 +357,6 @@ func TestExternalWorkspaces(t *testing.T) { "external-workspaces", "agent-instructions", ws.Name, - "external-agent", "--output=json", } inv, root := clitest.New(t, args...) @@ -389,7 +387,6 @@ func TestExternalWorkspaces(t *testing.T) { "external-workspaces", "agent-instructions", "non-existent-workspace", - "external-agent", } inv, root := clitest.New(t, args...) clitest.SetupConfig(t, member, root) @@ -415,15 +412,14 @@ func TestExternalWorkspaces(t *testing.T) { args := []string{ "external-workspaces", "agent-instructions", - ws.Name, - "non-existent-agent", + ws.Name + ".non-existent-agent", } inv, root := clitest.New(t, args...) clitest.SetupConfig(t, member, root) err := inv.Run() require.Error(t, err) - assert.Contains(t, err.Error(), "get external agent token for agent") + assert.Contains(t, err.Error(), "agent not found by name") }) t.Run("CreateWithTemplateVersion", func(t *testing.T) { diff --git a/cli/root.go b/cli/root.go index 57d6ff4d16d35..94f0287981e81 100644 --- a/cli/root.go +++ b/cli/root.go @@ -108,7 +108,7 @@ func (r *RootCmd) CoreSubcommands() []*serpent.Command { // Workspace Commands r.autoupdate(), r.configSSH(), - r.create(), + r.create(createOptions{}), r.deleteWorkspace(), r.favorite(), r.list(), @@ -126,8 +126,6 @@ func (r *RootCmd) CoreSubcommands() []*serpent.Command { r.unfavorite(), r.update(), r.whoami(), - - // External Workspace Commands r.externalWorkspaces(), // Hidden diff --git a/cli/testdata/coder_--help.golden b/cli/testdata/coder_--help.golden index 3b4b41a0d3b2d..b92b090fd491d 100644 --- a/cli/testdata/coder_--help.golden +++ b/cli/testdata/coder_--help.golden @@ -24,7 +24,7 @@ SUBCOMMANDS: dotfiles Personalize your workspace by applying a canonical dotfiles repository external-auth Manage external authentication - external-workspaces External workspace related commands + external-workspaces Create or manage external workspaces favorite Add a workspace to your favorites list List workspaces login Authenticate with Coder deployment diff --git a/cli/testdata/coder_external-workspaces_--help.golden b/cli/testdata/coder_external-workspaces_--help.golden index 2d7aec4eef7db..d8b1ca8363f66 100644 --- a/cli/testdata/coder_external-workspaces_--help.golden +++ b/cli/testdata/coder_external-workspaces_--help.golden @@ -3,7 +3,7 @@ coder v0.0.0-devel USAGE: coder external-workspaces [flags] [subcommand] - External workspace related commands + Create or manage external workspaces SUBCOMMANDS: agent-instructions Get the instructions for an external agent diff --git a/cli/testdata/coder_external-workspaces_agent-instructions_--help.golden b/cli/testdata/coder_external-workspaces_agent-instructions_--help.golden index 99d18a82bf73e..150a21313ed8c 100644 --- a/cli/testdata/coder_external-workspaces_agent-instructions_--help.golden +++ b/cli/testdata/coder_external-workspaces_agent-instructions_--help.golden @@ -1,8 +1,7 @@ coder v0.0.0-devel USAGE: - coder external-workspaces agent-instructions [flags] [workspace name] [agent - name] + coder external-workspaces agent-instructions [flags] [user/]workspace[.agent] Get the instructions for an external agent diff --git a/cli/testdata/coder_provisioner_list_--output_json.golden b/cli/testdata/coder_provisioner_list_--output_json.golden index b92794ab07e18..ad26225c2ed10 100644 --- a/cli/testdata/coder_provisioner_list_--output_json.golden +++ b/cli/testdata/coder_provisioner_list_--output_json.golden @@ -7,7 +7,7 @@ "last_seen_at": "====[timestamp]=====", "name": "test-daemon", "version": "v0.0.0-devel", - "api_version": "1.8", + "api_version": "1.9", "provisioners": [ "echo" ], diff --git a/docs/manifest.json b/docs/manifest.json index 3a9d798a72e1d..a3047150ad20c 100644 --- a/docs/manifest.json +++ b/docs/manifest.json @@ -1157,7 +1157,7 @@ }, { "title": "external-workspaces", - "description": "External workspace related commands", + "description": "Create or manage external workspaces", "path": "reference/cli/external-workspaces.md" }, { diff --git a/docs/reference/cli/external-workspaces.md b/docs/reference/cli/external-workspaces.md index b36e5352b9013..5e1f27a7794ad 100644 --- a/docs/reference/cli/external-workspaces.md +++ b/docs/reference/cli/external-workspaces.md @@ -1,7 +1,7 @@ # external-workspaces -External workspace related commands +Create or manage external workspaces ## Usage diff --git a/docs/reference/cli/external-workspaces_agent-instructions.md b/docs/reference/cli/external-workspaces_agent-instructions.md index 5285bd27ade24..d284a48de7173 100644 --- a/docs/reference/cli/external-workspaces_agent-instructions.md +++ b/docs/reference/cli/external-workspaces_agent-instructions.md @@ -6,7 +6,7 @@ Get the instructions for an external agent ## Usage ```console -coder external-workspaces agent-instructions [flags] [workspace name] [agent name] +coder external-workspaces agent-instructions [flags] [user/]workspace[.agent] ``` ## Options diff --git a/docs/reference/cli/index.md b/docs/reference/cli/index.md index 3a90838c11309..8a558030aeb9a 100644 --- a/docs/reference/cli/index.md +++ b/docs/reference/cli/index.md @@ -60,7 +60,7 @@ Coder — A tool for provisioning self-hosted development environments with Terr | [unfavorite](./unfavorite.md) | Remove a workspace from your favorites | | [update](./update.md) | Will update and start a given workspace if it is out of date. If the workspace is already running, it will be stopped first. | | [whoami](./whoami.md) | Fetch authenticated user info for Coder deployment | -| [external-workspaces](./external-workspaces.md) | External workspace related commands | +| [external-workspaces](./external-workspaces.md) | Create or manage external workspaces | | [support](./support.md) | Commands for troubleshooting issues with a Coder deployment. | | [server](./server.md) | Start a Coder server | | [features](./features.md) | List Enterprise features | From c462a6916a1a165afc8ace13c7a98e079663a146 Mon Sep 17 00:00:00 2001 From: Kacper Sawicki Date: Wed, 6 Aug 2025 13:21:05 +0000 Subject: [PATCH 13/28] Refactor init-script endpoint to use path params instead of query params --- cli/external_workspaces.go | 12 ++------ cli/external_workspaces_test.go | 2 +- coderd/coderd.go | 2 +- coderd/init_script.go | 31 +++++++++----------- site/src/modules/resources/AgentExternal.tsx | 6 +--- 5 files changed, 19 insertions(+), 34 deletions(-) diff --git a/cli/external_workspaces.go b/cli/external_workspaces.go index cd5d9bec0f207..aeed86e3b110a 100644 --- a/cli/external_workspaces.go +++ b/cli/external_workspaces.go @@ -133,11 +133,7 @@ func (r *RootCmd) externalWorkspaceAgentInstructions() *serpent.Command { return xerrors.Errorf("get external agent token for agent %q: %w", workspaceAgent.Name, err) } - initScriptURL := fmt.Sprintf("%s/api/v2/init-script", client.URL) - if workspaceAgent.OperatingSystem != "linux" || workspaceAgent.Architecture != "amd64" { - initScriptURL = fmt.Sprintf("%s/api/v2/init-script?os=%s&arch=%s", client.URL, workspaceAgent.OperatingSystem, workspaceAgent.Architecture) - } - + initScriptURL := fmt.Sprintf("%s/api/v2/init-script/%s/%s", client.URL, workspaceAgent.OperatingSystem, workspaceAgent.Architecture) agentInfo := externalAgent{ WorkspaceName: workspace.Name, AgentName: workspaceAgent.Name, @@ -244,11 +240,7 @@ func fetchExternalAgents(inv *serpent.Invocation, client *codersdk.Client, works 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) - } - + initScriptURL := fmt.Sprintf("%s/api/v2/init-script/%s/%s", client.URL, agent.OperatingSystem, agent.Architecture) externalAgents = append(externalAgents, externalAgent{ AgentName: agent.Name, AuthType: "token", diff --git a/cli/external_workspaces_test.go b/cli/external_workspaces_test.go index 4b758de7a4fb4..095657e9d8ea1 100644 --- a/cli/external_workspaces_test.go +++ b/cli/external_workspaces_test.go @@ -374,7 +374,7 @@ func TestExternalWorkspaces(t *testing.T) { require.NoError(t, json.Unmarshal(out.Bytes(), &agentInfo)) assert.Equal(t, "token", agentInfo["auth_type"]) assert.NotEmpty(t, agentInfo["auth_token"]) - assert.Contains(t, agentInfo["init_script"], "/api/v2/init-script") + assert.Contains(t, agentInfo["init_script"], "/api/v2/init-script/linux/amd64") }) t.Run("AgentInstructionsNonExistentWorkspace", func(t *testing.T) { diff --git a/coderd/coderd.go b/coderd/coderd.go index 209a32632c16f..f967fa5576f26 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -1551,7 +1551,7 @@ func New(options *Options) *API { r.Get("/", api.tailnetRPCConn) }) r.Route("/init-script", func(r chi.Router) { - r.Get("/", api.initScript) + r.Get("/{os}/{arch}", api.initScript) }) }) diff --git a/coderd/init_script.go b/coderd/init_script.go index 6ff8e3b8d69f4..7cd0bb3c94a70 100644 --- a/coderd/init_script.go +++ b/coderd/init_script.go @@ -5,6 +5,8 @@ import ( "net/http" "strings" + "github.com/go-chi/chi/v5" + "github.com/coder/coder/v2/provisionersdk" ) @@ -12,26 +14,21 @@ import ( // @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" +// @Param os path string true "Operating system" +// @Param arch path string true "Architecture" // @Success 200 "Success" -// @Router /init-script [get] +// @Router /init-script/{os}/{arch} [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 - } + os := strings.ToLower(chi.URLParam(r, "os")) + arch := strings.ToLower(chi.URLParam(r, "arch")) + + 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 - } + 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)] diff --git a/site/src/modules/resources/AgentExternal.tsx b/site/src/modules/resources/AgentExternal.tsx index 277b2b2b3f0a0..770d5452a7f79 100644 --- a/site/src/modules/resources/AgentExternal.tsx +++ b/site/src/modules/resources/AgentExternal.tsx @@ -21,11 +21,7 @@ export const AgentExternal: FC = ({ ); const origin = isChromatic() ? "https://example.com" : window.location.origin; - let initScriptURL = `${origin}/api/v2/init-script`; - if (agent.operating_system !== "linux" || agent.architecture !== "amd64") { - initScriptURL = `${initScriptURL}?os=${agent.operating_system}&arch=${agent.architecture}`; - } - + const initScriptURL = `${origin}/api/v2/init-script/${agent.operating_system}/${agent.architecture}`; useEffect(() => { if ( isExternalAgent && From 2d2dfec8e9b498bbbe463a9c98d29c0c4496d059 Mon Sep 17 00:00:00 2001 From: Kacper Sawicki Date: Wed, 6 Aug 2025 13:45:30 +0000 Subject: [PATCH 14/28] Refactor init-script endpoint, apply review suggestions for db --- coderd/database/dbauthz/dbauthz.go | 2 +- .../000356_external_agents.down.sql | 1 - .../migrations/000356_external_agents.up.sql | 4 +-- coderd/init_script.go | 11 +------ coderd/init_script_test.go | 31 +++++++++++++++++++ codersdk/init_script.go | 28 +++++++++++++++++ 6 files changed, 63 insertions(+), 14 deletions(-) create mode 100644 coderd/init_script_test.go create mode 100644 codersdk/init_script.go diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index 60da8db91a3d4..430ebf808b2a2 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -4707,7 +4707,7 @@ func (q *querier) UpdateTemplateVersionDescriptionByJobID(ctx context.Context, a } func (q *querier) UpdateTemplateVersionExternalAgentByJobID(ctx context.Context, arg database.UpdateTemplateVersionExternalAgentByJobIDParams) error { - // An actor is allowed to update the template version AI task flag if they are authorized to update the template. + // An actor is allowed to update the template version external agent flag if they are authorized to update the template. tv, err := q.db.GetTemplateVersionByJobID(ctx, arg.JobID) if err != nil { return err diff --git a/coderd/database/migrations/000356_external_agents.down.sql b/coderd/database/migrations/000356_external_agents.down.sql index 196a3b1675513..4d3d3a8c03803 100644 --- a/coderd/database/migrations/000356_external_agents.down.sql +++ b/coderd/database/migrations/000356_external_agents.down.sql @@ -36,7 +36,6 @@ ALTER TABLE workspace_builds DROP COLUMN has_external_agent; DROP VIEW workspace_build_with_user; --- We're adding the has_external_agent column. CREATE VIEW workspace_build_with_user AS SELECT workspace_builds.id, diff --git a/coderd/database/migrations/000356_external_agents.up.sql b/coderd/database/migrations/000356_external_agents.up.sql index 7306d2664c375..00b7d865dfd30 100644 --- a/coderd/database/migrations/000356_external_agents.up.sql +++ b/coderd/database/migrations/000356_external_agents.up.sql @@ -23,7 +23,7 @@ SELECT template_versions.archived, template_versions.source_example_id, template_versions.has_ai_task, - template_versions.has_external_agent, + template_versions.has_external_agent, 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 @@ -66,7 +66,7 @@ SELECT workspace_builds.template_version_preset_id, workspace_builds.has_ai_task, workspace_builds.ai_task_sidebar_app_id, - workspace_builds.has_external_agent, + workspace_builds.has_external_agent, COALESCE( visible_users.avatar_url, '' :: text diff --git a/coderd/init_script.go b/coderd/init_script.go index 7cd0bb3c94a70..c9ab7da6171a8 100644 --- a/coderd/init_script.go +++ b/coderd/init_script.go @@ -22,18 +22,9 @@ func (api *API) initScript(rw http.ResponseWriter, r *http.Request) { os := strings.ToLower(chi.URLParam(r, "os")) arch := strings.ToLower(chi.URLParam(r, "arch")) - if os != "linux" && os != "darwin" && os != "windows" { - rw.WriteHeader(http.StatusBadRequest) - return - } - 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) + rw.WriteHeader(http.StatusBadRequest) return } script = strings.ReplaceAll(script, "${ACCESS_URL}", api.AccessURL.String()+"/") diff --git a/coderd/init_script_test.go b/coderd/init_script_test.go new file mode 100644 index 0000000000000..b499f89ecccce --- /dev/null +++ b/coderd/init_script_test.go @@ -0,0 +1,31 @@ +package coderd_test + +import ( + "context" + "fmt" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/coder/coder/v2/coderd/coderdtest" +) + +func TestInitScript(t *testing.T) { + t.Parallel() + + t.Run("OK", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, nil) + script, err := client.InitScript(context.Background(), "windows", "amd64") + require.NoError(t, err) + require.NotEmpty(t, script) + }) + + t.Run("BadRequest", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, nil) + _, err := client.InitScript(context.Background(), "darwin", "armv7") + require.Error(t, err) + fmt.Printf("err: %+v\n", err) + }) +} diff --git a/codersdk/init_script.go b/codersdk/init_script.go new file mode 100644 index 0000000000000..d1adbf79460f0 --- /dev/null +++ b/codersdk/init_script.go @@ -0,0 +1,28 @@ +package codersdk + +import ( + "context" + "fmt" + "io" + "net/http" +) + +func (c *Client) InitScript(ctx context.Context, os, arch string) (string, error) { + url := fmt.Sprintf("/api/v2/init-script/%s/%s", os, arch) + res, err := c.Request(ctx, http.MethodGet, url, nil) + if err != nil { + return "", err + } + defer res.Body.Close() + + if res.StatusCode != http.StatusOK { + return "", ReadBodyAsError(res) + } + + script, err := io.ReadAll(res.Body) + if err != nil { + return "", err + } + + return string(script), nil +} From 387fc042686b3f8fcbf7c2eccde040fc453300a1 Mon Sep 17 00:00:00 2001 From: Kacper Sawicki Date: Wed, 6 Aug 2025 13:54:15 +0000 Subject: [PATCH 15/28] Apply FE review suggestions --- .../CodeExample/CodeExample.stories.tsx | 2 +- .../components/CodeExample/CodeExample.tsx | 25 ++++++------------- site/src/modules/resources/AgentExternal.tsx | 13 ++-------- 3 files changed, 11 insertions(+), 29 deletions(-) diff --git a/site/src/components/CodeExample/CodeExample.stories.tsx b/site/src/components/CodeExample/CodeExample.stories.tsx index 067c8c9791567..2a134998aa97d 100644 --- a/site/src/components/CodeExample/CodeExample.stories.tsx +++ b/site/src/components/CodeExample/CodeExample.stories.tsx @@ -37,6 +37,6 @@ export const Redact: Story = { secret: false, redactPattern: /CODER_AGENT_TOKEN="([^"]+)"/g, redactReplacement: `CODER_AGENT_TOKEN="********"`, - redactShowButton: true, + showRevealButton: true, }, }; diff --git a/site/src/components/CodeExample/CodeExample.tsx b/site/src/components/CodeExample/CodeExample.tsx index 8c24142b727c4..b69a220550958 100644 --- a/site/src/components/CodeExample/CodeExample.tsx +++ b/site/src/components/CodeExample/CodeExample.tsx @@ -13,10 +13,14 @@ import { CopyButton } from "../CopyButton/CopyButton"; interface CodeExampleProps { code: string; + /** Defaulting to true to be on the safe side; you should have to opt out of the secure option, not remember to opt in */ secret?: boolean; + /** Redact parts of the code if the user doesn't want to obfuscate the whole code */ redactPattern?: RegExp; + /** Replacement text for redacted content */ redactReplacement?: string; - redactShowButton?: boolean; + /** Show a button to reveal the redacted parts of the code */ + showRevealButton?: boolean; className?: string; } @@ -26,17 +30,10 @@ interface CodeExampleProps { export const CodeExample: FC = ({ code, className, - - // Defaulting to true to be on the safe side; you should have to opt out of - // the secure option, not remember to opt in secret = true, - - // Redact parts of the code if the user doesn't want to obfuscate the whole code redactPattern, redactReplacement = "********", - - // Show a button to show the redacted parts of the code - redactShowButton = false, + showRevealButton, }) => { const [showFullValue, setShowFullValue] = useState(false); @@ -78,8 +75,8 @@ export const CodeExample: FC = ({ )} -
- {redactShowButton && redactPattern && !secret && ( +
+ {showRevealButton && redactPattern && !secret && ( @@ -134,10 +131,4 @@ const styles = { secret: { "-webkit-text-security": "disc", // also supported by firefox }, - - actions: { - display: "flex", - alignItems: "center", - gap: 4, - }, } satisfies Record>; diff --git a/site/src/modules/resources/AgentExternal.tsx b/site/src/modules/resources/AgentExternal.tsx index 770d5452a7f79..436efd7a0515d 100644 --- a/site/src/modules/resources/AgentExternal.tsx +++ b/site/src/modules/resources/AgentExternal.tsx @@ -34,7 +34,7 @@ export const AgentExternal: FC = ({ }, [isExternalAgent, agent.status, workspace.id, agent.name]); return ( -
+

Please run the following command to attach an agent to the{" "} {workspace.name} workspace: @@ -44,17 +44,8 @@ export const AgentExternal: FC = ({ secret={false} redactPattern={/CODER_AGENT_TOKEN="([^"]+)"/g} redactReplacement={`CODER_AGENT_TOKEN="********"`} - redactShowButton={true} + showRevealButton={true} />

); }; - -const styles = { - externalAgentSection: (theme) => ({ - fontSize: 16, - color: theme.palette.text.secondary, - paddingBottom: 8, - lineHeight: 1.4, - }), -} satisfies Record>; From c2588ea2a89ba08183faadc1da0604bbe7580d0b Mon Sep 17 00:00:00 2001 From: Kacper Sawicki Date: Wed, 6 Aug 2025 15:05:18 +0000 Subject: [PATCH 16/28] Return 404 if workspace agent is authenticated through instance id --- cli/external_workspaces.go | 4 +-- coderd/workspaceagents.go | 7 ++++++ coderd/workspaceagents_test.go | 45 ++++++++++++++++++++++++++++++++++ codersdk/workspaces.go | 2 +- 4 files changed, 55 insertions(+), 3 deletions(-) diff --git a/cli/external_workspaces.go b/cli/external_workspaces.go index aeed86e3b110a..f2cbff9b86bba 100644 --- a/cli/external_workspaces.go +++ b/cli/external_workspaces.go @@ -128,7 +128,7 @@ func (r *RootCmd) externalWorkspaceAgentInstructions() *serpent.Command { return xerrors.Errorf("find workspace and agent: %w", err) } - credential, err := client.WorkspaceExternalAgentCredential(inv.Context(), workspace.ID, workspaceAgent.Name) + credential, err := client.WorkspaceExternalAgentCredentials(inv.Context(), workspace.ID, workspaceAgent.Name) if err != nil { return xerrors.Errorf("get external agent token for agent %q: %w", workspaceAgent.Name, err) } @@ -235,7 +235,7 @@ func fetchExternalAgents(inv *serpent.Invocation, client *codersdk.Client, works } agent := resource.Agents[0] - credential, err := client.WorkspaceExternalAgentCredential(inv.Context(), workspace.ID, agent.Name) + credential, err := client.WorkspaceExternalAgentCredentials(inv.Context(), workspace.ID, agent.Name) if err != nil { return nil, xerrors.Errorf("get external agent token for agent %q: %w", agent.Name, err) } diff --git a/coderd/workspaceagents.go b/coderd/workspaceagents.go index c98119fb35e17..7f3517280c669 100644 --- a/coderd/workspaceagents.go +++ b/coderd/workspaceagents.go @@ -2223,6 +2223,13 @@ func (api *API) workspaceExternalAgentCredentials(rw http.ResponseWriter, r *htt for _, agent := range agents { if agent.Name == agentName { + if agent.AuthInstanceID.Valid { + httpapi.Write(ctx, rw, http.StatusNotFound, codersdk.Response{ + Message: "External agent is authenticated with an instance ID.", + }) + return + } + httpapi.Write(ctx, rw, http.StatusOK, codersdk.ExternalAgentCredentials{ AgentToken: agent.AuthToken.String(), }) diff --git a/coderd/workspaceagents_test.go b/coderd/workspaceagents_test.go index 30859cb6391e6..a421674131857 100644 --- a/coderd/workspaceagents_test.go +++ b/coderd/workspaceagents_test.go @@ -3056,3 +3056,48 @@ func (p *pubsubReinitSpy) Subscribe(event string, listener pubsub.Listener) (can p.Unlock() return cancel, err } + +func TestWorkspaceExternalAgentCredentials(t *testing.T) { + t.Parallel() + client, db := coderdtest.NewWithDatabase(t, nil) + user := coderdtest.CreateFirstUser(t, client) + + t.Run("Success", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitShort) + + r := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{ + OrganizationID: user.OrganizationID, + OwnerID: user.UserID, + }).WithAgent(func(a []*proto.Agent) []*proto.Agent { + a[0].Name = "test-agent" + return a + }).Do() + + credentials, err := client.WorkspaceExternalAgentCredentials( + ctx, r.Workspace.ID, "test-agent") + require.NoError(t, err) + + require.Equal(t, r.AgentToken, credentials.AgentToken) + }) + + t.Run("WithInstanceID - should return 404", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitShort) + + r := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{ + OrganizationID: user.OrganizationID, + OwnerID: user.UserID, + }).WithAgent(func(a []*proto.Agent) []*proto.Agent { + a[0].Name = "test-agent" + a[0].Auth = &proto.Agent_InstanceId{ + InstanceId: uuid.New().String(), + } + return a + }).Do() + + _, err := client.WorkspaceExternalAgentCredentials(ctx, r.Workspace.ID, "test-agent") + require.Error(t, err) + require.Contains(t, err.Error(), "External agent is authenticated with an instance ID.") + }) +} diff --git a/codersdk/workspaces.go b/codersdk/workspaces.go index f47a8dd8f58cc..f31d37572ce4e 100644 --- a/codersdk/workspaces.go +++ b/codersdk/workspaces.go @@ -695,7 +695,7 @@ type ExternalAgentCredentials struct { AgentToken string `json:"agent_token"` } -func (c *Client) WorkspaceExternalAgentCredential(ctx context.Context, workspaceID uuid.UUID, agentName string) (ExternalAgentCredentials, error) { +func (c *Client) WorkspaceExternalAgentCredentials(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 { From 33dd778598d307833ce2a8a6e0648c5c06304c4d Mon Sep 17 00:00:00 2001 From: Kacper Sawicki Date: Thu, 7 Aug 2025 10:17:28 +0000 Subject: [PATCH 17/28] Merge UpdateTemplateVersionAITaskByJobID and UpdateTemplateVersionExternalAgentByJobID into one query --- coderd/apidoc/docs.go | 8 ++-- coderd/apidoc/swagger.json | 8 ++-- coderd/database/dbauthz/dbauthz.go | 28 ++---------- coderd/database/dbauthz/dbauthz_test.go | 9 ++-- coderd/database/dbgen/dbgen.go | 13 ++---- coderd/database/dbmetrics/querymetrics.go | 20 ++------- coderd/database/dbmock/dbmock.go | 26 +++-------- coderd/database/querier.go | 3 +- coderd/database/queries.sql.go | 44 +++++++------------ coderd/database/queries/templateversions.sql | 16 ++----- .../provisionerdserver/provisionerdserver.go | 12 ++--- docs/reference/api/initscript.md | 12 ++--- site/src/modules/resources/AgentExternal.tsx | 1 - 13 files changed, 59 insertions(+), 141 deletions(-) diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index a4af2b556b1fa..4213dc83bd246 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -1280,7 +1280,7 @@ const docTemplate = `{ } } }, - "/init-script": { + "/init-script/{os}/{arch}": { "get": { "produces": [ "text/plain" @@ -1295,13 +1295,15 @@ const docTemplate = `{ "type": "string", "description": "Operating system", "name": "os", - "in": "query" + "in": "path", + "required": true }, { "type": "string", "description": "Architecture", "name": "arch", - "in": "query" + "in": "path", + "required": true } ], "responses": { diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index a5f56ed81731b..af4d9d7e6c425 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -1108,7 +1108,7 @@ } } }, - "/init-script": { + "/init-script/{os}/{arch}": { "get": { "produces": ["text/plain"], "tags": ["InitScript"], @@ -1119,13 +1119,15 @@ "type": "string", "description": "Operating system", "name": "os", - "in": "query" + "in": "path", + "required": true }, { "type": "string", "description": "Architecture", "name": "arch", - "in": "query" + "in": "path", + "required": true } ], "responses": { diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index 430ebf808b2a2..a72beb0d332d7 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -4640,8 +4640,8 @@ func (q *querier) UpdateTemplateScheduleByID(ctx context.Context, arg database.U return update(q.log, q.auth, fetch, q.db.UpdateTemplateScheduleByID)(ctx, arg) } -func (q *querier) UpdateTemplateVersionAITaskByJobID(ctx context.Context, arg database.UpdateTemplateVersionAITaskByJobIDParams) error { - // An actor is allowed to update the template version AI task flag if they are authorized to update the template. +func (q *querier) UpdateTemplateVersionAITaskAndExternalAgentByJobID(ctx context.Context, arg database.UpdateTemplateVersionAITaskAndExternalAgentByJobIDParams) error { + // An actor is allowed to update the template version ai task and external agent flag if they are authorized to update the template. tv, err := q.db.GetTemplateVersionByJobID(ctx, arg.JobID) if err != nil { return err @@ -4659,7 +4659,7 @@ func (q *querier) UpdateTemplateVersionAITaskByJobID(ctx context.Context, arg da if err := q.authorizeContext(ctx, policy.ActionUpdate, obj); err != nil { return err } - return q.db.UpdateTemplateVersionAITaskByJobID(ctx, arg) + return q.db.UpdateTemplateVersionAITaskAndExternalAgentByJobID(ctx, arg) } func (q *querier) UpdateTemplateVersionByID(ctx context.Context, arg database.UpdateTemplateVersionByIDParams) error { @@ -4706,28 +4706,6 @@ func (q *querier) UpdateTemplateVersionDescriptionByJobID(ctx context.Context, a return q.db.UpdateTemplateVersionDescriptionByJobID(ctx, arg) } -func (q *querier) UpdateTemplateVersionExternalAgentByJobID(ctx context.Context, arg database.UpdateTemplateVersionExternalAgentByJobIDParams) error { - // An actor is allowed to update the template version external agent 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.UpdateTemplateVersionExternalAgentByJobID(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/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index 66a477ebfbaba..f54673a826173 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -1516,7 +1516,7 @@ func (s *MethodTestSuite) TestTemplate() { ID: t1.ID, }).Asserts(t1, policy.ActionUpdate) })) - s.Run("UpdateTemplateVersionAITaskByJobID", s.Subtest(func(db database.Store, check *expects) { + s.Run("UpdateTemplateVersionAITaskAndExternalAgentByJobID", s.Subtest(func(db database.Store, check *expects) { dbtestutil.DisableForeignKeysAndTriggers(s.T(), db) o := dbgen.Organization(s.T(), db, database.Organization{}) u := dbgen.User(s.T(), db, database.User{}) @@ -1529,9 +1529,10 @@ func (s *MethodTestSuite) TestTemplate() { JobID: job.ID, TemplateID: uuid.NullUUID{UUID: t.ID, Valid: true}, }) - check.Args(database.UpdateTemplateVersionAITaskByJobIDParams{ - JobID: job.ID, - HasAITask: sql.NullBool{Bool: true, Valid: true}, + check.Args(database.UpdateTemplateVersionAITaskAndExternalAgentByJobIDParams{ + JobID: job.ID, + HasAITask: sql.NullBool{Bool: true, Valid: true}, + HasExternalAgent: sql.NullBool{Bool: true, Valid: true}, }).Asserts(t, policy.ActionUpdate) })) s.Run("UpdateTemplateWorkspacesLastUsedAt", s.Subtest(func(db database.Store, check *expects) { diff --git a/coderd/database/dbgen/dbgen.go b/coderd/database/dbgen/dbgen.go index 50b1125ef98e7..df487e3b5ed3e 100644 --- a/coderd/database/dbgen/dbgen.go +++ b/coderd/database/dbgen/dbgen.go @@ -1058,17 +1058,10 @@ func TemplateVersion(t testing.TB, db database.Store, orig database.TemplateVers return err } - if hasAITask.Valid { - require.NoError(t, db.UpdateTemplateVersionAITaskByJobID(genCtx, database.UpdateTemplateVersionAITaskByJobIDParams{ - JobID: jobID, - HasAITask: hasAITask, - UpdatedAt: dbtime.Now(), - })) - } - - if hasExternalAgent.Valid { - require.NoError(t, db.UpdateTemplateVersionExternalAgentByJobID(genCtx, database.UpdateTemplateVersionExternalAgentByJobIDParams{ + if hasAITask.Valid && hasExternalAgent.Valid { + require.NoError(t, db.UpdateTemplateVersionAITaskAndExternalAgentByJobID(genCtx, database.UpdateTemplateVersionAITaskAndExternalAgentByJobIDParams{ JobID: jobID, + HasAITask: hasAITask, HasExternalAgent: hasExternalAgent, UpdatedAt: dbtime.Now(), })) diff --git a/coderd/database/dbmetrics/querymetrics.go b/coderd/database/dbmetrics/querymetrics.go index 206e417d55f88..4dee02581e3c0 100644 --- a/coderd/database/dbmetrics/querymetrics.go +++ b/coderd/database/dbmetrics/querymetrics.go @@ -89,13 +89,6 @@ func (m queryMetricsStore) DeleteOrganization(ctx context.Context, id uuid.UUID) return r0 } -func (m queryMetricsStore) UpdateTemplateVersionExternalAgentsByJobID(ctx context.Context, arg database.UpdateTemplateVersionExternalAgentByJobIDParams) error { - start := time.Now() - r0 := m.s.UpdateTemplateVersionExternalAgentByJobID(ctx, arg) - m.queryLatencies.WithLabelValues("UpdateTemplateVersionExternalAgentsByJobID").Observe(time.Since(start).Seconds()) - return r0 -} - func (m queryMetricsStore) AcquireLock(ctx context.Context, pgAdvisoryXactLock int64) error { start := time.Now() err := m.s.AcquireLock(ctx, pgAdvisoryXactLock) @@ -2875,10 +2868,10 @@ func (m queryMetricsStore) UpdateTemplateScheduleByID(ctx context.Context, arg d return err } -func (m queryMetricsStore) UpdateTemplateVersionAITaskByJobID(ctx context.Context, arg database.UpdateTemplateVersionAITaskByJobIDParams) error { +func (m queryMetricsStore) UpdateTemplateVersionAITaskAndExternalAgentByJobID(ctx context.Context, arg database.UpdateTemplateVersionAITaskAndExternalAgentByJobIDParams) error { start := time.Now() - r0 := m.s.UpdateTemplateVersionAITaskByJobID(ctx, arg) - m.queryLatencies.WithLabelValues("UpdateTemplateVersionAITaskByJobID").Observe(time.Since(start).Seconds()) + r0 := m.s.UpdateTemplateVersionAITaskAndExternalAgentByJobID(ctx, arg) + m.queryLatencies.WithLabelValues("UpdateTemplateVersionAITaskAndExternalAgentByJobID").Observe(time.Since(start).Seconds()) return r0 } @@ -2896,13 +2889,6 @@ func (m queryMetricsStore) UpdateTemplateVersionDescriptionByJobID(ctx context.C return err } -func (m queryMetricsStore) UpdateTemplateVersionExternalAgentByJobID(ctx context.Context, arg database.UpdateTemplateVersionExternalAgentByJobIDParams) error { - start := time.Now() - r0 := m.s.UpdateTemplateVersionExternalAgentByJobID(ctx, arg) - m.queryLatencies.WithLabelValues("UpdateTemplateVersionExternalAgentByJobID").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 83269eaf5bbab..425cd1deafcc4 100644 --- a/coderd/database/dbmock/dbmock.go +++ b/coderd/database/dbmock/dbmock.go @@ -6126,18 +6126,18 @@ func (mr *MockStoreMockRecorder) UpdateTemplateScheduleByID(ctx, arg any) *gomoc return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateTemplateScheduleByID", reflect.TypeOf((*MockStore)(nil).UpdateTemplateScheduleByID), ctx, arg) } -// UpdateTemplateVersionAITaskByJobID mocks base method. -func (m *MockStore) UpdateTemplateVersionAITaskByJobID(ctx context.Context, arg database.UpdateTemplateVersionAITaskByJobIDParams) error { +// UpdateTemplateVersionAITaskAndExternalAgentByJobID mocks base method. +func (m *MockStore) UpdateTemplateVersionAITaskAndExternalAgentByJobID(ctx context.Context, arg database.UpdateTemplateVersionAITaskAndExternalAgentByJobIDParams) error { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "UpdateTemplateVersionAITaskByJobID", ctx, arg) + ret := m.ctrl.Call(m, "UpdateTemplateVersionAITaskAndExternalAgentByJobID", ctx, arg) ret0, _ := ret[0].(error) return ret0 } -// UpdateTemplateVersionAITaskByJobID indicates an expected call of UpdateTemplateVersionAITaskByJobID. -func (mr *MockStoreMockRecorder) UpdateTemplateVersionAITaskByJobID(ctx, arg any) *gomock.Call { +// UpdateTemplateVersionAITaskAndExternalAgentByJobID indicates an expected call of UpdateTemplateVersionAITaskAndExternalAgentByJobID. +func (mr *MockStoreMockRecorder) UpdateTemplateVersionAITaskAndExternalAgentByJobID(ctx, arg any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateTemplateVersionAITaskByJobID", reflect.TypeOf((*MockStore)(nil).UpdateTemplateVersionAITaskByJobID), ctx, arg) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateTemplateVersionAITaskAndExternalAgentByJobID", reflect.TypeOf((*MockStore)(nil).UpdateTemplateVersionAITaskAndExternalAgentByJobID), ctx, arg) } // UpdateTemplateVersionByID mocks base method. @@ -6168,20 +6168,6 @@ func (mr *MockStoreMockRecorder) UpdateTemplateVersionDescriptionByJobID(ctx, ar return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateTemplateVersionDescriptionByJobID", reflect.TypeOf((*MockStore)(nil).UpdateTemplateVersionDescriptionByJobID), ctx, arg) } -// UpdateTemplateVersionExternalAgentByJobID mocks base method. -func (m *MockStore) UpdateTemplateVersionExternalAgentByJobID(ctx context.Context, arg database.UpdateTemplateVersionExternalAgentByJobIDParams) error { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "UpdateTemplateVersionExternalAgentByJobID", ctx, arg) - ret0, _ := ret[0].(error) - return ret0 -} - -// UpdateTemplateVersionExternalAgentByJobID indicates an expected call of UpdateTemplateVersionExternalAgentByJobID. -func (mr *MockStoreMockRecorder) UpdateTemplateVersionExternalAgentByJobID(ctx, arg any) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateTemplateVersionExternalAgentByJobID", reflect.TypeOf((*MockStore)(nil).UpdateTemplateVersionExternalAgentByJobID), 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/querier.go b/coderd/database/querier.go index 3cdfbb36a9e32..f67fe9ccfee78 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -604,10 +604,9 @@ type sqlcQuerier interface { UpdateTemplateDeletedByID(ctx context.Context, arg UpdateTemplateDeletedByIDParams) error UpdateTemplateMetaByID(ctx context.Context, arg UpdateTemplateMetaByIDParams) error UpdateTemplateScheduleByID(ctx context.Context, arg UpdateTemplateScheduleByIDParams) error - UpdateTemplateVersionAITaskByJobID(ctx context.Context, arg UpdateTemplateVersionAITaskByJobIDParams) error + UpdateTemplateVersionAITaskAndExternalAgentByJobID(ctx context.Context, arg UpdateTemplateVersionAITaskAndExternalAgentByJobIDParams) error UpdateTemplateVersionByID(ctx context.Context, arg UpdateTemplateVersionByIDParams) error UpdateTemplateVersionDescriptionByJobID(ctx context.Context, arg UpdateTemplateVersionDescriptionByJobIDParams) error - UpdateTemplateVersionExternalAgentByJobID(ctx context.Context, arg UpdateTemplateVersionExternalAgentByJobIDParams) 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 01d1f2fe5ab80..e1bfc0742f416 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -13100,24 +13100,31 @@ func (q *sqlQuerier) UnarchiveTemplateVersion(ctx context.Context, arg Unarchive return err } -const updateTemplateVersionAITaskByJobID = `-- name: UpdateTemplateVersionAITaskByJobID :exec +const updateTemplateVersionAITaskAndExternalAgentByJobID = `-- name: UpdateTemplateVersionAITaskAndExternalAgentByJobID :exec UPDATE template_versions SET has_ai_task = $2, - updated_at = $3 + has_external_agent = $3, + updated_at = $4 WHERE job_id = $1 ` -type UpdateTemplateVersionAITaskByJobIDParams struct { - JobID uuid.UUID `db:"job_id" json:"job_id"` - HasAITask sql.NullBool `db:"has_ai_task" json:"has_ai_task"` - UpdatedAt time.Time `db:"updated_at" json:"updated_at"` +type UpdateTemplateVersionAITaskAndExternalAgentByJobIDParams struct { + JobID uuid.UUID `db:"job_id" json:"job_id"` + HasAITask sql.NullBool `db:"has_ai_task" json:"has_ai_task"` + HasExternalAgent sql.NullBool `db:"has_external_agent" json:"has_external_agent"` + UpdatedAt time.Time `db:"updated_at" json:"updated_at"` } -func (q *sqlQuerier) UpdateTemplateVersionAITaskByJobID(ctx context.Context, arg UpdateTemplateVersionAITaskByJobIDParams) error { - _, err := q.db.ExecContext(ctx, updateTemplateVersionAITaskByJobID, arg.JobID, arg.HasAITask, arg.UpdatedAt) +func (q *sqlQuerier) UpdateTemplateVersionAITaskAndExternalAgentByJobID(ctx context.Context, arg UpdateTemplateVersionAITaskAndExternalAgentByJobIDParams) error { + _, err := q.db.ExecContext(ctx, updateTemplateVersionAITaskAndExternalAgentByJobID, + arg.JobID, + arg.HasAITask, + arg.HasExternalAgent, + arg.UpdatedAt, + ) return err } @@ -13173,27 +13180,6 @@ func (q *sqlQuerier) UpdateTemplateVersionDescriptionByJobID(ctx context.Context return err } -const updateTemplateVersionExternalAgentByJobID = `-- name: UpdateTemplateVersionExternalAgentByJobID :exec -UPDATE - template_versions -SET - has_external_agent = $2, - updated_at = $3 -WHERE - job_id = $1 -` - -type UpdateTemplateVersionExternalAgentByJobIDParams struct { - JobID uuid.UUID `db:"job_id" json:"job_id"` - HasExternalAgent sql.NullBool `db:"has_external_agent" json:"has_external_agent"` - UpdatedAt time.Time `db:"updated_at" json:"updated_at"` -} - -func (q *sqlQuerier) UpdateTemplateVersionExternalAgentByJobID(ctx context.Context, arg UpdateTemplateVersionExternalAgentByJobIDParams) error { - _, err := q.db.ExecContext(ctx, updateTemplateVersionExternalAgentByJobID, arg.JobID, arg.HasExternalAgent, arg.UpdatedAt) - return err -} - const updateTemplateVersionExternalAuthProvidersByJobID = `-- name: UpdateTemplateVersionExternalAuthProvidersByJobID :exec UPDATE template_versions diff --git a/coderd/database/queries/templateversions.sql b/coderd/database/queries/templateversions.sql index 5e9a799c60157..8b1a6227ee4a7 100644 --- a/coderd/database/queries/templateversions.sql +++ b/coderd/database/queries/templateversions.sql @@ -122,15 +122,6 @@ SET WHERE job_id = $1; --- name: UpdateTemplateVersionAITaskByJobID :exec -UPDATE - template_versions -SET - has_ai_task = $2, - updated_at = $3 -WHERE - job_id = $1; - -- name: GetPreviousTemplateVersion :one SELECT * @@ -235,11 +226,12 @@ WHERE template_versions.id IN (archived_versions.id) RETURNING template_versions.id; --- name: UpdateTemplateVersionExternalAgentByJobID :exec +-- name: UpdateTemplateVersionAITaskAndExternalAgentByJobID :exec UPDATE template_versions SET - has_external_agent = $2, - updated_at = $3 + has_ai_task = $2, + has_external_agent = $3, + updated_at = $4 WHERE job_id = $1; diff --git a/coderd/provisionerdserver/provisionerdserver.go b/coderd/provisionerdserver/provisionerdserver.go index a0d173ab9ea33..37198034782d9 100644 --- a/coderd/provisionerdserver/provisionerdserver.go +++ b/coderd/provisionerdserver/provisionerdserver.go @@ -1720,19 +1720,12 @@ 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.UpdateTemplateVersionAITaskByJobID(ctx, database.UpdateTemplateVersionAITaskByJobIDParams{ + err = db.UpdateTemplateVersionAITaskAndExternalAgentByJobID(ctx, database.UpdateTemplateVersionAITaskAndExternalAgentByJobIDParams{ JobID: jobID, HasAITask: sql.NullBool{ Bool: jobType.TemplateImport.HasAiTasks, Valid: true, }, - UpdatedAt: now, - }) - if err != nil { - return xerrors.Errorf("update template version external auth providers: %w", err) - } - err = db.UpdateTemplateVersionExternalAgentByJobID(ctx, database.UpdateTemplateVersionExternalAgentByJobIDParams{ - JobID: jobID, HasExternalAgent: sql.NullBool{ Bool: jobType.TemplateImport.HasExternalAgents, Valid: true, @@ -1740,8 +1733,9 @@ func (s *server) completeTemplateImportJob(ctx context.Context, job database.Pro UpdatedAt: now, }) if err != nil { - return xerrors.Errorf("update template version external agents: %w", err) + return xerrors.Errorf("update template version ai task and external agent: %w", err) } + // Process terraform values plan := jobType.TemplateImport.Plan moduleFiles := jobType.TemplateImport.ModuleFiles diff --git a/docs/reference/api/initscript.md b/docs/reference/api/initscript.md index 7140e37bec23f..ecd8c8008a6a4 100644 --- a/docs/reference/api/initscript.md +++ b/docs/reference/api/initscript.md @@ -6,18 +6,18 @@ ```shell # Example request using curl -curl -X GET http://coder-server:8080/api/v2/init-script +curl -X GET http://coder-server:8080/api/v2/init-script/{os}/{arch} ``` -`GET /init-script` +`GET /init-script/{os}/{arch}` ### Parameters -| Name | In | Type | Required | Description | -|--------|-------|--------|----------|------------------| -| `os` | query | string | false | Operating system | -| `arch` | query | string | false | Architecture | +| Name | In | Type | Required | Description | +|--------|------|--------|----------|------------------| +| `os` | path | string | true | Operating system | +| `arch` | path | string | true | Architecture | ### Responses diff --git a/site/src/modules/resources/AgentExternal.tsx b/site/src/modules/resources/AgentExternal.tsx index 436efd7a0515d..d025a8ea59bfe 100644 --- a/site/src/modules/resources/AgentExternal.tsx +++ b/site/src/modules/resources/AgentExternal.tsx @@ -1,4 +1,3 @@ -import type { Interpolation, Theme } from "@emotion/react"; import { API } from "api/api"; import type { Workspace, WorkspaceAgent } from "api/typesGenerated"; import isChromatic from "chromatic/isChromatic"; From c413479a88fd47f4413a3ac2f4ffe8716b8ecf7b Mon Sep 17 00:00:00 2001 From: Kacper Sawicki Date: Thu, 7 Aug 2025 13:14:05 +0000 Subject: [PATCH 18/28] update external agent credentials to include command in response --- cli/external_workspaces.go | 14 +++---- cli/external_workspaces_test.go | 2 +- coderd/apidoc/docs.go | 3 ++ coderd/apidoc/swagger.json | 3 ++ coderd/workspaceagents.go | 42 +++++++++++++------- codersdk/workspaces.go | 1 + docs/reference/api/agents.md | 3 +- docs/reference/api/schemas.md | 4 +- site/src/api/typesGenerated.ts | 1 + site/src/modules/resources/AgentExternal.tsx | 5 ++- 10 files changed, 50 insertions(+), 28 deletions(-) diff --git a/cli/external_workspaces.go b/cli/external_workspaces.go index f2cbff9b86bba..29090f317ab3e 100644 --- a/cli/external_workspaces.go +++ b/cli/external_workspaces.go @@ -128,18 +128,17 @@ func (r *RootCmd) externalWorkspaceAgentInstructions() *serpent.Command { return xerrors.Errorf("find workspace and agent: %w", err) } - credential, err := client.WorkspaceExternalAgentCredentials(inv.Context(), workspace.ID, workspaceAgent.Name) + credentials, err := client.WorkspaceExternalAgentCredentials(inv.Context(), workspace.ID, workspaceAgent.Name) if err != nil { return xerrors.Errorf("get external agent token for agent %q: %w", workspaceAgent.Name, err) } - initScriptURL := fmt.Sprintf("%s/api/v2/init-script/%s/%s", client.URL, workspaceAgent.OperatingSystem, workspaceAgent.Architecture) agentInfo := externalAgent{ WorkspaceName: workspace.Name, AgentName: workspaceAgent.Name, AuthType: "token", - AuthToken: credential.AgentToken, - InitScript: initScriptURL, + AuthToken: credentials.AgentToken, + InitScript: credentials.Command, } out, err := formatter.Format(inv.Context(), agentInfo) @@ -235,17 +234,16 @@ func fetchExternalAgents(inv *serpent.Invocation, client *codersdk.Client, works } agent := resource.Agents[0] - credential, err := client.WorkspaceExternalAgentCredentials(inv.Context(), workspace.ID, agent.Name) + credentials, err := client.WorkspaceExternalAgentCredentials(inv.Context(), workspace.ID, agent.Name) if err != nil { return nil, xerrors.Errorf("get external agent token for agent %q: %w", agent.Name, err) } - initScriptURL := fmt.Sprintf("%s/api/v2/init-script/%s/%s", client.URL, agent.OperatingSystem, agent.Architecture) externalAgents = append(externalAgents, externalAgent{ AgentName: agent.Name, AuthType: "token", - AuthToken: credential.AgentToken, - InitScript: initScriptURL, + AuthToken: credentials.AgentToken, + InitScript: credentials.Command, }) } diff --git a/cli/external_workspaces_test.go b/cli/external_workspaces_test.go index 095657e9d8ea1..e6934c090149e 100644 --- a/cli/external_workspaces_test.go +++ b/cli/external_workspaces_test.go @@ -374,7 +374,7 @@ func TestExternalWorkspaces(t *testing.T) { require.NoError(t, json.Unmarshal(out.Bytes(), &agentInfo)) assert.Equal(t, "token", agentInfo["auth_type"]) assert.NotEmpty(t, agentInfo["auth_token"]) - assert.Contains(t, agentInfo["init_script"], "/api/v2/init-script/linux/amd64") + assert.NotEmpty(t, agentInfo["init_script"]) }) t.Run("AgentInstructionsNonExistentWorkspace", func(t *testing.T) { diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 4213dc83bd246..c9d3d7c858f90 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -12981,6 +12981,9 @@ const docTemplate = `{ "properties": { "agent_token": { "type": "string" + }, + "command": { + "type": "string" } } }, diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index af4d9d7e6c425..c2211dd19b3e0 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -11635,6 +11635,9 @@ "properties": { "agent_token": { "type": "string" + }, + "command": { + "type": "string" } } }, diff --git a/coderd/workspaceagents.go b/coderd/workspaceagents.go index 7f3517280c669..f31c027b859dd 100644 --- a/coderd/workspaceagents.go +++ b/coderd/workspaceagents.go @@ -2221,23 +2221,35 @@ func (api *API) workspaceExternalAgentCredentials(rw http.ResponseWriter, r *htt return } - for _, agent := range agents { - if agent.Name == agentName { - if agent.AuthInstanceID.Valid { - httpapi.Write(ctx, rw, http.StatusNotFound, codersdk.Response{ - Message: "External agent is authenticated with an instance ID.", - }) - return - } - - httpapi.Write(ctx, rw, http.StatusOK, codersdk.ExternalAgentCredentials{ - AgentToken: agent.AuthToken.String(), - }) - return + var agent *database.WorkspaceAgent + for i := range agents { + if agents[i].Name == agentName { + agent = &agents[i] + break } } + if agent == nil { + httpapi.Write(ctx, rw, http.StatusNotFound, codersdk.Response{ + Message: fmt.Sprintf("External agent '%s' not found in workspace.", agentName), + }) + return + } + + if agent.AuthInstanceID.Valid { + httpapi.Write(ctx, rw, http.StatusNotFound, codersdk.Response{ + Message: "External agent is authenticated with an instance ID.", + }) + return + } + + initScriptURL := fmt.Sprintf("%s/api/v2/init-script/%s/%s", api.AccessURL.String(), agent.OperatingSystem, agent.Architecture) + command := fmt.Sprintf("CODER_AGENT_TOKEN=%q curl -fsSL %q | sh", agent.AuthToken.String(), initScriptURL) + if agent.OperatingSystem == "windows" { + command = fmt.Sprintf("$env:CODER_AGENT_TOKEN=%q; iwr -useb %q | iex", agent.AuthToken.String(), initScriptURL) + } - httpapi.Write(ctx, rw, http.StatusNotFound, codersdk.Response{ - Message: fmt.Sprintf("External agent '%s' not found in workspace.", agentName), + httpapi.Write(ctx, rw, http.StatusOK, codersdk.ExternalAgentCredentials{ + AgentToken: agent.AuthToken.String(), + Command: command, }) } diff --git a/codersdk/workspaces.go b/codersdk/workspaces.go index f31d37572ce4e..39d52325df448 100644 --- a/codersdk/workspaces.go +++ b/codersdk/workspaces.go @@ -692,6 +692,7 @@ func (c *Client) UpdateWorkspaceACL(ctx context.Context, workspaceID uuid.UUID, // ExternalAgentCredentials contains the credentials needed for an external agent to connect to Coder. type ExternalAgentCredentials struct { + Command string `json:"command"` AgentToken string `json:"agent_token"` } diff --git a/docs/reference/api/agents.md b/docs/reference/api/agents.md index 5cbc76dc63178..68f12a4cc1398 100644 --- a/docs/reference/api/agents.md +++ b/docs/reference/api/agents.md @@ -1265,7 +1265,8 @@ curl -X GET http://coder-server:8080/api/v2/workspaces/{workspace}/external-agen ```json { - "agent_token": "string" + "agent_token": "string", + "command": "string" } ``` diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index 8b79a0d05ad63..4dc34300061aa 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -3326,7 +3326,8 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o ```json { - "agent_token": "string" + "agent_token": "string", + "command": "string" } ``` @@ -3335,6 +3336,7 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o | Name | Type | Required | Restrictions | Description | |---------------|--------|----------|--------------|-------------| | `agent_token` | string | false | | | +| `command` | string | false | | | ## codersdk.ExternalAuth diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 6bcd7e2f36b01..a64ec016cd8b2 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -933,6 +933,7 @@ export const Experiments: Experiment[] = [ // From codersdk/workspaces.go export interface ExternalAgentCredentials { + readonly command: string; readonly agent_token: string; } diff --git a/site/src/modules/resources/AgentExternal.tsx b/site/src/modules/resources/AgentExternal.tsx index d025a8ea59bfe..64fea9e23ed12 100644 --- a/site/src/modules/resources/AgentExternal.tsx +++ b/site/src/modules/resources/AgentExternal.tsx @@ -18,9 +18,9 @@ export const AgentExternal: FC = ({ const [externalAgentToken, setExternalAgentToken] = useState( null, ); + const [command, setCommand] = useState(null); const origin = isChromatic() ? "https://example.com" : window.location.origin; - const initScriptURL = `${origin}/api/v2/init-script/${agent.operating_system}/${agent.architecture}`; useEffect(() => { if ( isExternalAgent && @@ -28,6 +28,7 @@ export const AgentExternal: FC = ({ ) { API.getWorkspaceAgentCredentials(workspace.id, agent.name).then((res) => { setExternalAgentToken(res.agent_token); + setCommand(res.command); }); } }, [isExternalAgent, agent.status, workspace.id, agent.name]); @@ -39,7 +40,7 @@ export const AgentExternal: FC = ({ {workspace.name} workspace:

Date: Fri, 8 Aug 2025 09:14:30 +0000 Subject: [PATCH 19/28] Bump terraform-provider-coder to v2.10.0 --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 748e6538fdcba..047382e6191e6 100644 --- a/go.mod +++ b/go.mod @@ -101,7 +101,7 @@ require ( github.com/coder/quartz v0.2.1 github.com/coder/retry v1.5.1 github.com/coder/serpent v0.10.0 - github.com/coder/terraform-provider-coder/v2 v2.9.0 + github.com/coder/terraform-provider-coder/v2 v2.10.0 github.com/coder/websocket v1.8.13 github.com/coder/wgtunnel v0.1.13-0.20240522110300-ade90dfb2da0 github.com/coreos/go-oidc/v3 v3.15.0 diff --git a/go.sum b/go.sum index cb29e1ab12f81..58cee5c213138 100644 --- a/go.sum +++ b/go.sum @@ -934,8 +934,8 @@ github.com/coder/tailscale v1.1.1-0.20250729141742-067f1e5d9716 h1:hi7o0sA+RPBq8 github.com/coder/tailscale v1.1.1-0.20250729141742-067f1e5d9716/go.mod h1:l7ml5uu7lFh5hY28lGYM4b/oFSmuPHYX6uk4RAu23Lc= github.com/coder/terraform-config-inspect v0.0.0-20250107175719-6d06d90c630e h1:JNLPDi2P73laR1oAclY6jWzAbucf70ASAvf5mh2cME0= github.com/coder/terraform-config-inspect v0.0.0-20250107175719-6d06d90c630e/go.mod h1:Gz/z9Hbn+4KSp8A2FBtNszfLSdT2Tn/uAKGuVqqWmDI= -github.com/coder/terraform-provider-coder/v2 v2.9.0 h1:nd9d1/qHTdx5foBLZoy0SWCc0W13GQUbPTzeGsuLlU0= -github.com/coder/terraform-provider-coder/v2 v2.9.0/go.mod h1:f8xPh0riDTRwqoPWkjas5VgIBaiRiWH+STb0TZw2fgY= +github.com/coder/terraform-provider-coder/v2 v2.10.0 h1:cGPMfARGHKb80kZsbDX/t/YKwMOwI5zkIyVCQziHR2M= +github.com/coder/terraform-provider-coder/v2 v2.10.0/go.mod h1:f8xPh0riDTRwqoPWkjas5VgIBaiRiWH+STb0TZw2fgY= github.com/coder/trivy v0.0.0-20250527170238-9416a59d7019 h1:MHkv/W7l9eRAN9gOG0qZ1TLRGWIIfNi92273vPAQ8Fs= github.com/coder/trivy v0.0.0-20250527170238-9416a59d7019/go.mod h1:eqk+w9RLBmbd/cB5XfPZFuVn77cf/A6fB7qmEVeSmXk= github.com/coder/websocket v1.8.13 h1:f3QZdXy7uGVz+4uCJy2nTZyM0yTBj8yANEHhqlXZ9FE= From 7cc6861b4ca1f4059204ebc6d97a5899da84aab6 Mon Sep 17 00:00:00 2001 From: Kacper Sawicki Date: Fri, 8 Aug 2025 10:05:13 +0000 Subject: [PATCH 20/28] Regenerate sql --- coderd/database/dump.sql | 2 +- ...wn.sql => 000358_external_agents.down.sql} | 0 ...s.up.sql => 000358_external_agents.up.sql} | 0 coderd/database/queries.sql.go | 54 +++++++++---------- 4 files changed, 28 insertions(+), 28 deletions(-) rename coderd/database/migrations/{000356_external_agents.down.sql => 000358_external_agents.down.sql} (100%) rename coderd/database/migrations/{000356_external_agents.up.sql => 000358_external_agents.up.sql} (100%) diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index 39d8e9cdba861..5e6a62c52b186 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -2239,7 +2239,7 @@ CREATE TABLE workspace_builds ( has_ai_task boolean, ai_task_sidebar_app_id uuid, has_external_agent boolean, - CONSTRAINT workspace_builds_ai_task_sidebar_app_id_required CHECK (((((has_ai_task IS NULL) OR (has_ai_task = false)) AND (ai_task_sidebar_app_id IS NULL)) OR ((has_ai_task = true) AND (ai_task_sidebar_app_id IS NOT NULL)))) + CONSTRAINT workspace_builds_ai_task_sidebar_app_id_required CHECK (((((has_ai_task IS NULL) OR (has_ai_task = false)) AND (ai_task_sidebar_app_id IS NULL)) OR ((has_ai_task = true) AND (ai_task_sidebar_app_id IS NOT NULL)))), CONSTRAINT workspace_builds_deadline_below_max_deadline CHECK ((((deadline <> '0001-01-01 00:00:00+00'::timestamp with time zone) AND (deadline <= max_deadline)) OR (max_deadline = '0001-01-01 00:00:00+00'::timestamp with time zone))) ); diff --git a/coderd/database/migrations/000356_external_agents.down.sql b/coderd/database/migrations/000358_external_agents.down.sql similarity index 100% rename from coderd/database/migrations/000356_external_agents.down.sql rename to coderd/database/migrations/000358_external_agents.down.sql diff --git a/coderd/database/migrations/000356_external_agents.up.sql b/coderd/database/migrations/000358_external_agents.up.sql similarity index 100% rename from coderd/database/migrations/000356_external_agents.up.sql rename to coderd/database/migrations/000358_external_agents.up.sql diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 59c0ca37c13f3..964c6483ea8a1 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -8554,11 +8554,11 @@ func (q *sqlQuerier) InsertProvisionerJobLogs(ctx context.Context, arg InsertPro } const updateProvisionerJobLogsLength = `-- name: UpdateProvisionerJobLogsLength :exec -UPDATE +UPDATE provisioner_jobs -SET +SET logs_length = logs_length + $2 -WHERE +WHERE id = $1 ` @@ -8573,11 +8573,11 @@ func (q *sqlQuerier) UpdateProvisionerJobLogsLength(ctx context.Context, arg Upd } const updateProvisionerJobLogsOverflowed = `-- name: UpdateProvisionerJobLogsOverflowed :exec -UPDATE +UPDATE provisioner_jobs -SET +SET logs_overflowed = $2 -WHERE +WHERE id = $1 ` @@ -9595,7 +9595,7 @@ FROM provisioner_keys WHERE organization_id = $1 -AND +AND lower(name) = lower($2) ` @@ -9711,10 +9711,10 @@ WHERE AND -- exclude reserved built-in key id != '00000000-0000-0000-0000-000000000001'::uuid -AND +AND -- exclude reserved user-auth key id != '00000000-0000-0000-0000-000000000002'::uuid -AND +AND -- exclude reserved psk key id != '00000000-0000-0000-0000-000000000003'::uuid ` @@ -11498,7 +11498,7 @@ func (q *sqlQuerier) GetTailnetTunnelPeerIDs(ctx context.Context, srcID uuid.UUI } const updateTailnetPeerStatusByCoordinator = `-- name: UpdateTailnetPeerStatusByCoordinator :exec -UPDATE +UPDATE tailnet_peers SET status = $2 @@ -12113,8 +12113,8 @@ WHERE -- Filter by has_external_agent in latest version AND CASE - WHEN $8 :: boolean IS NOT NULL THEN - tv.has_external_agent = $8 :: boolean + WHEN $10 :: boolean IS NOT NULL THEN + tv.has_external_agent = $10 :: boolean ELSE true END -- Authorize Filter clause will be injected below in GetAuthorizedTemplates @@ -12123,15 +12123,15 @@ 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"` - AuthorID uuid.UUID `db:"author_id" json:"author_id"` - AuthorUsername string `db:"author_username" json:"author_username"` + 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"` + AuthorID uuid.UUID `db:"author_id" json:"author_id"` + AuthorUsername string `db:"author_username" json:"author_username"` HasExternalAgent sql.NullBool `db:"has_external_agent" json:"has_external_agent"` } @@ -13474,14 +13474,14 @@ DO $$ DECLARE table_record record; BEGIN - FOR table_record IN - SELECT table_schema, table_name - FROM information_schema.tables + FOR table_record IN + SELECT table_schema, table_name + FROM information_schema.tables WHERE table_schema NOT IN ('pg_catalog', 'information_schema') AND table_type = 'BASE TABLE' LOOP - EXECUTE format('ALTER TABLE %I.%I DISABLE TRIGGER ALL', - table_record.table_schema, + EXECUTE format('ALTER TABLE %I.%I DISABLE TRIGGER ALL', + table_record.table_schema, table_record.table_name); END LOOP; END; @@ -17277,7 +17277,7 @@ WITH agent_stats AS ( coalesce((PERCENTILE_CONT(0.95) WITHIN GROUP (ORDER BY connection_median_latency_ms)), -1)::FLOAT AS workspace_connection_latency_95 FROM workspace_agent_stats -- The greater than 0 is to support legacy agents that don't report connection_median_latency_ms. - WHERE workspace_agent_stats.created_at > $1 AND connection_median_latency_ms > 0 + WHERE workspace_agent_stats.created_at > $1 AND connection_median_latency_ms > 0 GROUP BY user_id, agent_id, workspace_id, template_id ), latest_agent_stats AS ( SELECT From da68c20744e28fa138e36e2c3ed872fe3f494efb Mon Sep 17 00:00:00 2001 From: Kacper Sawicki Date: Fri, 8 Aug 2025 10:28:34 +0000 Subject: [PATCH 21/28] Fix lint & tests --- cli/external_workspaces_test.go | 2 +- coderd/searchquery/search.go | 18 +++++++++--------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/cli/external_workspaces_test.go b/cli/external_workspaces_test.go index e6934c090149e..cf89ec4c2cb0c 100644 --- a/cli/external_workspaces_test.go +++ b/cli/external_workspaces_test.go @@ -393,7 +393,7 @@ func TestExternalWorkspaces(t *testing.T) { err := inv.Run() require.Error(t, err) - assert.Contains(t, err.Error(), "get workspace by name") + assert.Contains(t, err.Error(), "Resource not found") }) t.Run("AgentInstructionsNonExistentAgent", func(t *testing.T) { diff --git a/coderd/searchquery/search.go b/coderd/searchquery/search.go index 1967b102d76bc..e70d402d17c1e 100644 --- a/coderd/searchquery/search.go +++ b/coderd/searchquery/search.go @@ -278,15 +278,15 @@ func Templates(ctx context.Context, db database.Store, actorID uuid.UUID, query parser := httpapi.NewQueryParamParser() filter := database.GetTemplatesWithFilterParams{ - Deleted: parser.Boolean(values, false, "deleted"), - OrganizationID: parseOrganization(ctx, db, parser, values, "organization"), - 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"), - HasAITask: parser.NullableBoolean(values, sql.NullBool{}, "has-ai-task"), - AuthorID: parser.UUID(values, uuid.Nil, "author_id"), - AuthorUsername: parser.String(values, "", "author"), + Deleted: parser.Boolean(values, false, "deleted"), + OrganizationID: parseOrganization(ctx, db, parser, values, "organization"), + 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"), + HasAITask: parser.NullableBoolean(values, sql.NullBool{}, "has-ai-task"), + AuthorID: parser.UUID(values, uuid.Nil, "author_id"), + AuthorUsername: parser.String(values, "", "author"), HasExternalAgent: parser.NullableBoolean(values, sql.NullBool{}, "has-external-agent"), } From 2e24741bf566dcf698828a6ccf29a890c9a01289 Mon Sep 17 00:00:00 2001 From: Kacper Sawicki Date: Fri, 8 Aug 2025 10:46:15 +0000 Subject: [PATCH 22/28] Regenerate dump.sql --- coderd/database/dump.sql | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index 5e6a62c52b186..76e6cb2a0f0ed 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -608,15 +608,15 @@ BEGIN IF workspace_count > 0 THEN error_parts := array_append(error_parts, workspace_count || ' workspaces'); END IF; - + IF template_count > 0 THEN error_parts := array_append(error_parts, template_count || ' templates'); END IF; - + IF provisioner_keys_count > 0 THEN error_parts := array_append(error_parts, provisioner_keys_count || ' provisioner keys'); END IF; - + error_message := error_message || array_to_string(error_parts, ', ') || ' that must be deleted first'; RAISE EXCEPTION '%', error_message; END; From 682ea603ae2997bae233431a2fdf6450e05397a4 Mon Sep 17 00:00:00 2001 From: Kacper Sawicki Date: Fri, 8 Aug 2025 11:07:08 +0000 Subject: [PATCH 23/28] Fix provision test --- provisioner/terraform/provision_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/provisioner/terraform/provision_test.go b/provisioner/terraform/provision_test.go index 1aaa6da465308..90a34e6d03a8c 100644 --- a/provisioner/terraform/provision_test.go +++ b/provisioner/terraform/provision_test.go @@ -1147,7 +1147,7 @@ func TestProvision(t *testing.T) { } } resource "coder_external_agent" "example" { - token = "123" + agent_id = "123" } `, }, From 51967a5e3ae3042eb93847ae8fb87bf724e0ff2c Mon Sep 17 00:00:00 2001 From: Kacper Sawicki Date: Fri, 8 Aug 2025 11:25:03 +0000 Subject: [PATCH 24/28] update external agent credentials summary and adjust authorization checks for new endpoint --- coderd/coderdtest/swaggerparser.go | 6 ++++-- .../migrations/000358_external_agents.down.sql | 13 +++++++------ coderd/workspaceagents.go | 2 +- 3 files changed, 12 insertions(+), 9 deletions(-) diff --git a/coderd/coderdtest/swaggerparser.go b/coderd/coderdtest/swaggerparser.go index 7cef0d8d9f9cb..b94473ee83bda 100644 --- a/coderd/coderdtest/swaggerparser.go +++ b/coderd/coderdtest/swaggerparser.go @@ -310,7 +310,8 @@ func assertSecurityDefined(t *testing.T, comment SwaggerComment) { comment.router == "/" || comment.router == "/users/login" || comment.router == "/users/otp/request" || - comment.router == "/users/otp/change-password" { + comment.router == "/users/otp/change-password" || + comment.router == "/init-script/{os}/{arch}" { return // endpoints do not require authorization } assert.Containsf(t, authorizedSecurityTags, comment.security, "@Security must be either of these options: %v", authorizedSecurityTags) @@ -361,7 +362,8 @@ func assertProduce(t *testing.T, comment SwaggerComment) { (comment.router == "/licenses/{id}" && comment.method == "delete") || (comment.router == "/debug/coordinator" && comment.method == "get") || (comment.router == "/debug/tailnet" && comment.method == "get") || - (comment.router == "/workspaces/{workspace}/acl" && comment.method == "patch") { + (comment.router == "/workspaces/{workspace}/acl" && comment.method == "patch") || + (comment.router == "/init-script/{os}/{arch}" && comment.method == "get") { return // Exception: HTTP 200 is returned without response entity } diff --git a/coderd/database/migrations/000358_external_agents.down.sql b/coderd/database/migrations/000358_external_agents.down.sql index 4d3d3a8c03803..519c2ce08b7a7 100644 --- a/coderd/database/migrations/000358_external_agents.down.sql +++ b/coderd/database/migrations/000358_external_agents.down.sql @@ -1,6 +1,8 @@ -ALTER TABLE template_versions DROP COLUMN has_external_agent; - DROP VIEW template_version_with_user; +DROP VIEW workspace_build_with_user; + +ALTER TABLE template_versions DROP COLUMN has_external_agent; +ALTER TABLE workspace_builds DROP COLUMN has_external_agent; -- Recreate `template_version_with_user` as defined in dump.sql CREATE VIEW template_version_with_user AS @@ -32,10 +34,7 @@ FROM COMMENT ON VIEW template_version_with_user IS 'Joins in the username + avatar url of the created by user.'; -ALTER TABLE workspace_builds DROP COLUMN has_external_agent; - -DROP VIEW workspace_build_with_user; - +-- Recreate `workspace_build_with_user` as defined in dump.sql CREATE VIEW workspace_build_with_user AS SELECT workspace_builds.id, @@ -75,3 +74,5 @@ FROM ); COMMENT ON VIEW workspace_build_with_user IS 'Joins in the username + avatar url of the initiated by user.'; + + diff --git a/coderd/workspaceagents.go b/coderd/workspaceagents.go index f31c027b859dd..09c4f95520093 100644 --- a/coderd/workspaceagents.go +++ b/coderd/workspaceagents.go @@ -2186,7 +2186,7 @@ func convertWorkspaceAgentLog(logEntry database.WorkspaceAgentLog) codersdk.Work } } -// @Summary Get external agent credentials +// @Summary Get workspace external agent credentials // @ID get-workspace-external-agent-credentials // @Security CoderSessionToken // @Produce json From f060324371c0d8496d909e8812672a4545d88595 Mon Sep 17 00:00:00 2001 From: Kacper Sawicki Date: Fri, 8 Aug 2025 12:48:00 +0000 Subject: [PATCH 25/28] merge UpdateWorkspaceBuildAITaskByID with UpdateWorkspaceBuildExternalAgentByID --- coderd/apidoc/docs.go | 2 +- coderd/apidoc/swagger.json | 2 +- coderd/database/dbauthz/dbauthz.go | 22 +--------- coderd/database/dbauthz/dbauthz_test.go | 12 +++--- coderd/database/dbgen/dbgen.go | 17 +++----- coderd/database/dbmetrics/querymetrics.go | 13 ++---- coderd/database/dbmock/dbmock.go | 26 +++-------- coderd/database/querier.go | 3 +- coderd/database/queries.sql.go | 43 ++++++------------- coderd/database/queries/workspacebuilds.sql | 13 ++---- .../provisionerdserver/provisionerdserver.go | 30 +++++-------- docs/reference/api/agents.md | 2 +- 12 files changed, 54 insertions(+), 131 deletions(-) diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index ab0dbbdb0a957..b11f357108c03 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -10317,7 +10317,7 @@ const docTemplate = `{ "tags": [ "Agents" ], - "summary": "Get external agent credentials", + "summary": "Get workspace external agent credentials", "operationId": "get-workspace-external-agent-credentials", "parameters": [ { diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 971dc49bfbe48..461938a4510eb 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -9123,7 +9123,7 @@ ], "produces": ["application/json"], "tags": ["Agents"], - "summary": "Get external agent credentials", + "summary": "Get workspace external agent credentials", "operationId": "get-workspace-external-agent-credentials", "parameters": [ { diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index f9a8c18a191af..7c951dcebbdef 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -5104,7 +5104,7 @@ func (q *querier) UpdateWorkspaceAutostart(ctx context.Context, arg database.Upd return update(q.log, q.auth, fetch, q.db.UpdateWorkspaceAutostart)(ctx, arg) } -func (q *querier) UpdateWorkspaceBuildAITaskByID(ctx context.Context, arg database.UpdateWorkspaceBuildAITaskByIDParams) error { +func (q *querier) UpdateWorkspaceBuildAITaskAndExternalAgentByID(ctx context.Context, arg database.UpdateWorkspaceBuildAITaskAndExternalAgentByIDParams) error { build, err := q.db.GetWorkspaceBuildByID(ctx, arg.ID) if err != nil { return err @@ -5119,7 +5119,7 @@ func (q *querier) UpdateWorkspaceBuildAITaskByID(ctx context.Context, arg databa if err != nil { return err } - return q.db.UpdateWorkspaceBuildAITaskByID(ctx, arg) + return q.db.UpdateWorkspaceBuildAITaskAndExternalAgentByID(ctx, arg) } // UpdateWorkspaceBuildCostByID is used by the provisioning system to update the cost of a workspace build. @@ -5148,24 +5148,6 @@ func (q *querier) UpdateWorkspaceBuildDeadlineByID(ctx context.Context, arg data return q.db.UpdateWorkspaceBuildDeadlineByID(ctx, arg) } -func (q *querier) UpdateWorkspaceBuildExternalAgentByID(ctx context.Context, arg database.UpdateWorkspaceBuildExternalAgentByIDParams) error { - build, err := q.db.GetWorkspaceBuildByID(ctx, arg.ID) - if err != nil { - return err - } - - workspace, err := q.db.GetWorkspaceByID(ctx, build.WorkspaceID) - if err != nil { - return err - } - - err = q.authorizeContext(ctx, policy.ActionUpdate, workspace.RBACObject()) - if err != nil { - return err - } - return q.db.UpdateWorkspaceBuildExternalAgentByID(ctx, arg) -} - func (q *querier) UpdateWorkspaceBuildProvisionerStateByID(ctx context.Context, arg database.UpdateWorkspaceBuildProvisionerStateByIDParams) error { if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceSystem); err != nil { return err diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index e8286a8ac4066..95316c1f858db 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -3228,7 +3228,7 @@ func (s *MethodTestSuite) TestWorkspace() { Deadline: b.Deadline, }).Asserts(w, policy.ActionUpdate) })) - s.Run("UpdateWorkspaceBuildAITaskByID", s.Subtest(func(db database.Store, check *expects) { + s.Run("UpdateWorkspaceBuildAITaskAndExternalAgentByID", s.Subtest(func(db database.Store, check *expects) { u := dbgen.User(s.T(), db, database.User{}) o := dbgen.Organization(s.T(), db, database.Organization{}) tpl := dbgen.Template(s.T(), db, database.Template{ @@ -3256,10 +3256,12 @@ func (s *MethodTestSuite) TestWorkspace() { res := dbgen.WorkspaceResource(s.T(), db, database.WorkspaceResource{JobID: b.JobID}) agt := dbgen.WorkspaceAgent(s.T(), db, database.WorkspaceAgent{ResourceID: res.ID}) app := dbgen.WorkspaceApp(s.T(), db, database.WorkspaceApp{AgentID: agt.ID}) - check.Args(database.UpdateWorkspaceBuildAITaskByIDParams{ - HasAITask: sql.NullBool{Bool: true, Valid: true}, - SidebarAppID: uuid.NullUUID{UUID: app.ID, Valid: true}, - ID: b.ID, + check.Args(database.UpdateWorkspaceBuildAITaskAndExternalAgentByIDParams{ + ID: b.ID, + HasAITask: sql.NullBool{Bool: true, Valid: true}, + HasExternalAgent: sql.NullBool{Bool: true, Valid: true}, + SidebarAppID: uuid.NullUUID{UUID: app.ID, Valid: true}, + UpdatedAt: b.UpdatedAt, }).Asserts(w, policy.ActionUpdate) })) s.Run("SoftDeleteWorkspaceByID", s.Subtest(func(db database.Store, check *expects) { diff --git a/coderd/database/dbgen/dbgen.go b/coderd/database/dbgen/dbgen.go index 7a443fc0402d8..6e450bc4ee09c 100644 --- a/coderd/database/dbgen/dbgen.go +++ b/coderd/database/dbgen/dbgen.go @@ -471,19 +471,12 @@ func WorkspaceBuild(t testing.TB, db database.Store, orig database.WorkspaceBuil require.NoError(t, err) } - if hasAITask.Valid { - require.NoError(t, db.UpdateWorkspaceBuildAITaskByID(genCtx, database.UpdateWorkspaceBuildAITaskByIDParams{ - HasAITask: hasAITask, - SidebarAppID: sidebarAppID, - UpdatedAt: dbtime.Now(), - ID: buildID, - })) - } - - if hasExternalAgent.Valid { - require.NoError(t, db.UpdateWorkspaceBuildExternalAgentByID(genCtx, database.UpdateWorkspaceBuildExternalAgentByIDParams{ + if hasAITask.Valid || hasExternalAgent.Valid { + require.NoError(t, db.UpdateWorkspaceBuildAITaskAndExternalAgentByID(genCtx, database.UpdateWorkspaceBuildAITaskAndExternalAgentByIDParams{ ID: buildID, + HasAITask: hasAITask, HasExternalAgent: hasExternalAgent, + SidebarAppID: sidebarAppID, UpdatedAt: dbtime.Now(), })) } @@ -1058,7 +1051,7 @@ func TemplateVersion(t testing.TB, db database.Store, orig database.TemplateVers return err } - if hasAITask.Valid && hasExternalAgent.Valid { + if hasAITask.Valid || hasExternalAgent.Valid { require.NoError(t, db.UpdateTemplateVersionAITaskAndExternalAgentByJobID(genCtx, database.UpdateTemplateVersionAITaskAndExternalAgentByJobIDParams{ JobID: jobID, HasAITask: hasAITask, diff --git a/coderd/database/dbmetrics/querymetrics.go b/coderd/database/dbmetrics/querymetrics.go index ee7e22473bc7d..77321c23717b6 100644 --- a/coderd/database/dbmetrics/querymetrics.go +++ b/coderd/database/dbmetrics/querymetrics.go @@ -3127,10 +3127,10 @@ func (m queryMetricsStore) UpdateWorkspaceAutostart(ctx context.Context, arg dat return err } -func (m queryMetricsStore) UpdateWorkspaceBuildAITaskByID(ctx context.Context, arg database.UpdateWorkspaceBuildAITaskByIDParams) error { +func (m queryMetricsStore) UpdateWorkspaceBuildAITaskAndExternalAgentByID(ctx context.Context, arg database.UpdateWorkspaceBuildAITaskAndExternalAgentByIDParams) error { start := time.Now() - r0 := m.s.UpdateWorkspaceBuildAITaskByID(ctx, arg) - m.queryLatencies.WithLabelValues("UpdateWorkspaceBuildAITaskByID").Observe(time.Since(start).Seconds()) + r0 := m.s.UpdateWorkspaceBuildAITaskAndExternalAgentByID(ctx, arg) + m.queryLatencies.WithLabelValues("UpdateWorkspaceBuildAITaskAndExternalAgentByID").Observe(time.Since(start).Seconds()) return r0 } @@ -3148,13 +3148,6 @@ func (m queryMetricsStore) UpdateWorkspaceBuildDeadlineByID(ctx context.Context, return r0 } -func (m queryMetricsStore) UpdateWorkspaceBuildExternalAgentByID(ctx context.Context, arg database.UpdateWorkspaceBuildExternalAgentByIDParams) error { - start := time.Now() - r0 := m.s.UpdateWorkspaceBuildExternalAgentByID(ctx, arg) - m.queryLatencies.WithLabelValues("UpdateWorkspaceBuildExternalAgentByID").Observe(time.Since(start).Seconds()) - return r0 -} - func (m queryMetricsStore) UpdateWorkspaceBuildProvisionerStateByID(ctx context.Context, arg database.UpdateWorkspaceBuildProvisionerStateByIDParams) error { start := time.Now() r0 := m.s.UpdateWorkspaceBuildProvisionerStateByID(ctx, arg) diff --git a/coderd/database/dbmock/dbmock.go b/coderd/database/dbmock/dbmock.go index f76f8fcdd63a6..9e32034c04025 100644 --- a/coderd/database/dbmock/dbmock.go +++ b/coderd/database/dbmock/dbmock.go @@ -6661,18 +6661,18 @@ func (mr *MockStoreMockRecorder) UpdateWorkspaceAutostart(ctx, arg any) *gomock. return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateWorkspaceAutostart", reflect.TypeOf((*MockStore)(nil).UpdateWorkspaceAutostart), ctx, arg) } -// UpdateWorkspaceBuildAITaskByID mocks base method. -func (m *MockStore) UpdateWorkspaceBuildAITaskByID(ctx context.Context, arg database.UpdateWorkspaceBuildAITaskByIDParams) error { +// UpdateWorkspaceBuildAITaskAndExternalAgentByID mocks base method. +func (m *MockStore) UpdateWorkspaceBuildAITaskAndExternalAgentByID(ctx context.Context, arg database.UpdateWorkspaceBuildAITaskAndExternalAgentByIDParams) error { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "UpdateWorkspaceBuildAITaskByID", ctx, arg) + ret := m.ctrl.Call(m, "UpdateWorkspaceBuildAITaskAndExternalAgentByID", ctx, arg) ret0, _ := ret[0].(error) return ret0 } -// UpdateWorkspaceBuildAITaskByID indicates an expected call of UpdateWorkspaceBuildAITaskByID. -func (mr *MockStoreMockRecorder) UpdateWorkspaceBuildAITaskByID(ctx, arg any) *gomock.Call { +// UpdateWorkspaceBuildAITaskAndExternalAgentByID indicates an expected call of UpdateWorkspaceBuildAITaskAndExternalAgentByID. +func (mr *MockStoreMockRecorder) UpdateWorkspaceBuildAITaskAndExternalAgentByID(ctx, arg any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateWorkspaceBuildAITaskByID", reflect.TypeOf((*MockStore)(nil).UpdateWorkspaceBuildAITaskByID), ctx, arg) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateWorkspaceBuildAITaskAndExternalAgentByID", reflect.TypeOf((*MockStore)(nil).UpdateWorkspaceBuildAITaskAndExternalAgentByID), ctx, arg) } // UpdateWorkspaceBuildCostByID mocks base method. @@ -6703,20 +6703,6 @@ func (mr *MockStoreMockRecorder) UpdateWorkspaceBuildDeadlineByID(ctx, arg any) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateWorkspaceBuildDeadlineByID", reflect.TypeOf((*MockStore)(nil).UpdateWorkspaceBuildDeadlineByID), ctx, arg) } -// UpdateWorkspaceBuildExternalAgentByID mocks base method. -func (m *MockStore) UpdateWorkspaceBuildExternalAgentByID(ctx context.Context, arg database.UpdateWorkspaceBuildExternalAgentByIDParams) error { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "UpdateWorkspaceBuildExternalAgentByID", ctx, arg) - ret0, _ := ret[0].(error) - return ret0 -} - -// UpdateWorkspaceBuildExternalAgentByID indicates an expected call of UpdateWorkspaceBuildExternalAgentByID. -func (mr *MockStoreMockRecorder) UpdateWorkspaceBuildExternalAgentByID(ctx, arg any) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateWorkspaceBuildExternalAgentByID", reflect.TypeOf((*MockStore)(nil).UpdateWorkspaceBuildExternalAgentByID), ctx, arg) -} - // UpdateWorkspaceBuildProvisionerStateByID mocks base method. func (m *MockStore) UpdateWorkspaceBuildProvisionerStateByID(ctx context.Context, arg database.UpdateWorkspaceBuildProvisionerStateByIDParams) error { m.ctrl.T.Helper() diff --git a/coderd/database/querier.go b/coderd/database/querier.go index 39610c1107ead..9ad918b607dc4 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -641,10 +641,9 @@ type sqlcQuerier interface { UpdateWorkspaceAppHealthByID(ctx context.Context, arg UpdateWorkspaceAppHealthByIDParams) error UpdateWorkspaceAutomaticUpdates(ctx context.Context, arg UpdateWorkspaceAutomaticUpdatesParams) error UpdateWorkspaceAutostart(ctx context.Context, arg UpdateWorkspaceAutostartParams) error - UpdateWorkspaceBuildAITaskByID(ctx context.Context, arg UpdateWorkspaceBuildAITaskByIDParams) error + UpdateWorkspaceBuildAITaskAndExternalAgentByID(ctx context.Context, arg UpdateWorkspaceBuildAITaskAndExternalAgentByIDParams) error UpdateWorkspaceBuildCostByID(ctx context.Context, arg UpdateWorkspaceBuildCostByIDParams) error UpdateWorkspaceBuildDeadlineByID(ctx context.Context, arg UpdateWorkspaceBuildDeadlineByIDParams) error - UpdateWorkspaceBuildExternalAgentByID(ctx context.Context, arg UpdateWorkspaceBuildExternalAgentByIDParams) error UpdateWorkspaceBuildProvisionerStateByID(ctx context.Context, arg UpdateWorkspaceBuildProvisionerStateByIDParams) error UpdateWorkspaceDeletedByID(ctx context.Context, arg UpdateWorkspaceDeletedByIDParams) error UpdateWorkspaceDormantDeletingAt(ctx context.Context, arg UpdateWorkspaceDormantDeletingAtParams) (WorkspaceTable, error) diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 964c6483ea8a1..d2bca981e8498 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -19211,27 +19211,30 @@ func (q *sqlQuerier) InsertWorkspaceBuild(ctx context.Context, arg InsertWorkspa return err } -const updateWorkspaceBuildAITaskByID = `-- name: UpdateWorkspaceBuildAITaskByID :exec +const updateWorkspaceBuildAITaskAndExternalAgentByID = `-- name: UpdateWorkspaceBuildAITaskAndExternalAgentByID :exec UPDATE workspace_builds SET has_ai_task = $1, ai_task_sidebar_app_id = $2, - updated_at = $3::timestamptz -WHERE id = $4::uuid + has_external_agent = $3, + updated_at = $4::timestamptz +WHERE id = $5::uuid ` -type UpdateWorkspaceBuildAITaskByIDParams struct { - HasAITask sql.NullBool `db:"has_ai_task" json:"has_ai_task"` - SidebarAppID uuid.NullUUID `db:"sidebar_app_id" json:"sidebar_app_id"` - UpdatedAt time.Time `db:"updated_at" json:"updated_at"` - ID uuid.UUID `db:"id" json:"id"` +type UpdateWorkspaceBuildAITaskAndExternalAgentByIDParams struct { + HasAITask sql.NullBool `db:"has_ai_task" json:"has_ai_task"` + SidebarAppID uuid.NullUUID `db:"sidebar_app_id" json:"sidebar_app_id"` + HasExternalAgent sql.NullBool `db:"has_external_agent" json:"has_external_agent"` + UpdatedAt time.Time `db:"updated_at" json:"updated_at"` + ID uuid.UUID `db:"id" json:"id"` } -func (q *sqlQuerier) UpdateWorkspaceBuildAITaskByID(ctx context.Context, arg UpdateWorkspaceBuildAITaskByIDParams) error { - _, err := q.db.ExecContext(ctx, updateWorkspaceBuildAITaskByID, +func (q *sqlQuerier) UpdateWorkspaceBuildAITaskAndExternalAgentByID(ctx context.Context, arg UpdateWorkspaceBuildAITaskAndExternalAgentByIDParams) error { + _, err := q.db.ExecContext(ctx, updateWorkspaceBuildAITaskAndExternalAgentByID, arg.HasAITask, arg.SidebarAppID, + arg.HasExternalAgent, arg.UpdatedAt, arg.ID, ) @@ -19284,26 +19287,6 @@ func (q *sqlQuerier) UpdateWorkspaceBuildDeadlineByID(ctx context.Context, arg U return err } -const updateWorkspaceBuildExternalAgentByID = `-- name: UpdateWorkspaceBuildExternalAgentByID :exec -UPDATE - workspace_builds -SET - has_external_agent = $1, - updated_at = $2::timestamptz -WHERE id = $3::uuid -` - -type UpdateWorkspaceBuildExternalAgentByIDParams struct { - HasExternalAgent sql.NullBool `db:"has_external_agent" json:"has_external_agent"` - UpdatedAt time.Time `db:"updated_at" json:"updated_at"` - ID uuid.UUID `db:"id" json:"id"` -} - -func (q *sqlQuerier) UpdateWorkspaceBuildExternalAgentByID(ctx context.Context, arg UpdateWorkspaceBuildExternalAgentByIDParams) error { - _, err := q.db.ExecContext(ctx, updateWorkspaceBuildExternalAgentByID, arg.HasExternalAgent, arg.UpdatedAt, arg.ID) - return err -} - const updateWorkspaceBuildProvisionerStateByID = `-- name: UpdateWorkspaceBuildProvisionerStateByID :exec UPDATE workspace_builds diff --git a/coderd/database/queries/workspacebuilds.sql b/coderd/database/queries/workspacebuilds.sql index c4929f1c04871..b7a02236e408a 100644 --- a/coderd/database/queries/workspacebuilds.sql +++ b/coderd/database/queries/workspacebuilds.sql @@ -151,15 +151,6 @@ SET updated_at = @updated_at::timestamptz WHERE id = @id::uuid; --- name: UpdateWorkspaceBuildAITaskByID :exec -UPDATE - workspace_builds -SET - has_ai_task = @has_ai_task, - ai_task_sidebar_app_id = @sidebar_app_id, - updated_at = @updated_at::timestamptz -WHERE id = @id::uuid; - -- name: GetActiveWorkspaceBuildsByTemplateID :many SELECT wb.* FROM ( @@ -254,10 +245,12 @@ WHERE ORDER BY tv.name ASC, wb.build_number DESC; --- name: UpdateWorkspaceBuildExternalAgentByID :exec +-- name: UpdateWorkspaceBuildAITaskAndExternalAgentByID :exec UPDATE workspace_builds SET + has_ai_task = @has_ai_task, + ai_task_sidebar_app_id = @sidebar_app_id, has_external_agent = @has_external_agent, updated_at = @updated_at::timestamptz WHERE id = @id::uuid; diff --git a/coderd/provisionerdserver/provisionerdserver.go b/coderd/provisionerdserver/provisionerdserver.go index 2f4b60c9d4c38..8a9b2c2d54818 100644 --- a/coderd/provisionerdserver/provisionerdserver.go +++ b/coderd/provisionerdserver/provisionerdserver.go @@ -1964,22 +1964,6 @@ func (s *server) completeWorkspaceBuildJob(ctx context.Context, job database.Pro sidebarAppID = uuid.NullUUID{UUID: id, Valid: true} } - // Regardless of whether there is an AI task or not, update the field to indicate one way or the other since it - // always defaults to nil. ONLY if has_ai_task=true MUST ai_task_sidebar_app_id be set. - err = db.UpdateWorkspaceBuildAITaskByID(ctx, database.UpdateWorkspaceBuildAITaskByIDParams{ - ID: workspaceBuild.ID, - HasAITask: sql.NullBool{ - Bool: hasAITask, - Valid: true, - }, - SidebarAppID: sidebarAppID, - UpdatedAt: now, - }) - if err != nil { - return xerrors.Errorf("update workspace build ai tasks flag: %w", err) - } - - // Check if there is a coder_external_agent resource in the workspace build hasExternalAgent := false for _, resource := range jobType.WorkspaceBuild.Resources { if resource.Type == "coder_external_agent" { @@ -1987,16 +1971,24 @@ func (s *server) completeWorkspaceBuildJob(ctx context.Context, job database.Pro break } } - err = db.UpdateWorkspaceBuildExternalAgentByID(ctx, database.UpdateWorkspaceBuildExternalAgentByIDParams{ + + // Regardless of whether there is an AI task or not, update the field to indicate one way or the other since it + // always defaults to nil. ONLY if has_ai_task=true MUST ai_task_sidebar_app_id be set. + err = db.UpdateWorkspaceBuildAITaskAndExternalAgentByID(ctx, database.UpdateWorkspaceBuildAITaskAndExternalAgentByIDParams{ ID: workspaceBuild.ID, + HasAITask: sql.NullBool{ + Bool: hasAITask, + Valid: true, + }, HasExternalAgent: sql.NullBool{ Bool: hasExternalAgent, Valid: true, }, - UpdatedAt: now, + SidebarAppID: sidebarAppID, + UpdatedAt: now, }) if err != nil { - return xerrors.Errorf("update workspace build external agent flag: %w", err) + return xerrors.Errorf("update workspace build ai tasks and external agent flag: %w", err) } // Insert timings inside the transaction now diff --git a/docs/reference/api/agents.md b/docs/reference/api/agents.md index 68f12a4cc1398..e72117e96e350 100644 --- a/docs/reference/api/agents.md +++ b/docs/reference/api/agents.md @@ -1239,7 +1239,7 @@ Status Code **200** To perform this operation, you must be authenticated. [Learn more](authentication.md). -## Get external agent credentials +## Get workspace external agent credentials ### Code samples From ff6e8faf946661eaed3112e1660a6b42f9f1961c Mon Sep 17 00:00:00 2001 From: Kacper Sawicki Date: Fri, 8 Aug 2025 16:20:31 +0200 Subject: [PATCH 26/28] Apply suggestions from code review Co-authored-by: Dean Sheather --- cli/external_workspaces.go | 3 +-- coderd/database/migrations/000358_external_agents.down.sql | 1 - 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/cli/external_workspaces.go b/cli/external_workspaces.go index 29090f317ab3e..5ea1d2d8c9410 100644 --- a/cli/external_workspaces.go +++ b/cli/external_workspaces.go @@ -5,9 +5,8 @@ import ( "fmt" "strings" - "golang.org/x/xerrors" - "github.com/google/uuid" + "golang.org/x/xerrors" "github.com/coder/coder/v2/cli/cliui" "github.com/coder/coder/v2/codersdk" diff --git a/coderd/database/migrations/000358_external_agents.down.sql b/coderd/database/migrations/000358_external_agents.down.sql index 519c2ce08b7a7..a17d0cc7982a6 100644 --- a/coderd/database/migrations/000358_external_agents.down.sql +++ b/coderd/database/migrations/000358_external_agents.down.sql @@ -75,4 +75,3 @@ FROM COMMENT ON VIEW workspace_build_with_user IS 'Joins in the username + avatar url of the initiated by user.'; - From e2a71820c44770547386552eb339abcce288a979 Mon Sep 17 00:00:00 2001 From: Kacper Sawicki Date: Fri, 8 Aug 2025 14:40:58 +0000 Subject: [PATCH 27/28] Apply review suggestions --- ...al_workspaces.go => externalworkspaces.go} | 0 ...ces_test.go => externalworkspaces_test.go} | 0 coderd/database/dbauthz/dbauthz.go | 80 ++++++------ coderd/database/dbauthz/dbauthz_test.go | 8 +- coderd/database/dbgen/dbgen.go | 4 +- coderd/database/dbmetrics/querymetrics.go | 28 ++--- coderd/database/dbmock/dbmock.go | 56 ++++----- coderd/database/querier.go | 4 +- coderd/database/queries.sql.go | 116 +++++++++--------- coderd/database/queries/templateversions.sql | 2 +- coderd/database/queries/workspacebuilds.sql | 2 +- coderd/init_script_test.go | 31 ----- coderd/{init_script.go => initscript.go} | 6 +- coderd/initscript_test.go | 45 +++++++ .../provisionerdserver/provisionerdserver.go | 4 +- coderd/workspaceagents_test.go | 33 ++++- codersdk/{init_script.go => initscript.go} | 0 17 files changed, 233 insertions(+), 186 deletions(-) rename cli/{external_workspaces.go => externalworkspaces.go} (100%) rename cli/{external_workspaces_test.go => externalworkspaces_test.go} (100%) delete mode 100644 coderd/init_script_test.go rename coderd/{init_script.go => initscript.go} (81%) create mode 100644 coderd/initscript_test.go rename codersdk/{init_script.go => initscript.go} (100%) diff --git a/cli/external_workspaces.go b/cli/externalworkspaces.go similarity index 100% rename from cli/external_workspaces.go rename to cli/externalworkspaces.go diff --git a/cli/external_workspaces_test.go b/cli/externalworkspaces_test.go similarity index 100% rename from cli/external_workspaces_test.go rename to cli/externalworkspaces_test.go diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index 7c951dcebbdef..bdb762735f86a 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -1235,6 +1235,46 @@ func (q *querier) authorizeProvisionerJob(ctx context.Context, job database.Prov return nil } +func (q *querier) UpdateTemplateVersionFlagsByJobID(ctx context.Context, arg database.UpdateTemplateVersionFlagsByJobIDParams) error { + // An actor is allowed to update the template version ai task and external agent 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.UpdateTemplateVersionFlagsByJobID(ctx, arg) +} + +func (q *querier) UpdateWorkspaceBuildFlagsByID(ctx context.Context, arg database.UpdateWorkspaceBuildFlagsByIDParams) error { + build, err := q.db.GetWorkspaceBuildByID(ctx, arg.ID) + if err != nil { + return err + } + + workspace, err := q.db.GetWorkspaceByID(ctx, build.WorkspaceID) + if err != nil { + return err + } + + err = q.authorizeContext(ctx, policy.ActionUpdate, workspace.RBACObject()) + if err != nil { + return err + } + return q.db.UpdateWorkspaceBuildFlagsByID(ctx, arg) +} + func (q *querier) AcquireLock(ctx context.Context, id int64) error { return q.db.AcquireLock(ctx, id) } @@ -4701,28 +4741,6 @@ func (q *querier) UpdateTemplateScheduleByID(ctx context.Context, arg database.U return update(q.log, q.auth, fetch, q.db.UpdateTemplateScheduleByID)(ctx, arg) } -func (q *querier) UpdateTemplateVersionAITaskAndExternalAgentByJobID(ctx context.Context, arg database.UpdateTemplateVersionAITaskAndExternalAgentByJobIDParams) error { - // An actor is allowed to update the template version ai task and external agent 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.UpdateTemplateVersionAITaskAndExternalAgentByJobID(ctx, arg) -} - func (q *querier) UpdateTemplateVersionByID(ctx context.Context, arg database.UpdateTemplateVersionByIDParams) error { // An actor is allowed to update the template version if they are authorized to update the template. tv, err := q.db.GetTemplateVersionByID(ctx, arg.ID) @@ -5104,24 +5122,6 @@ func (q *querier) UpdateWorkspaceAutostart(ctx context.Context, arg database.Upd return update(q.log, q.auth, fetch, q.db.UpdateWorkspaceAutostart)(ctx, arg) } -func (q *querier) UpdateWorkspaceBuildAITaskAndExternalAgentByID(ctx context.Context, arg database.UpdateWorkspaceBuildAITaskAndExternalAgentByIDParams) error { - build, err := q.db.GetWorkspaceBuildByID(ctx, arg.ID) - if err != nil { - return err - } - - workspace, err := q.db.GetWorkspaceByID(ctx, build.WorkspaceID) - if err != nil { - return err - } - - err = q.authorizeContext(ctx, policy.ActionUpdate, workspace.RBACObject()) - if err != nil { - return err - } - return q.db.UpdateWorkspaceBuildAITaskAndExternalAgentByID(ctx, arg) -} - // UpdateWorkspaceBuildCostByID is used by the provisioning system to update the cost of a workspace build. func (q *querier) UpdateWorkspaceBuildCostByID(ctx context.Context, arg database.UpdateWorkspaceBuildCostByIDParams) error { if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceSystem); err != nil { diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index 95316c1f858db..1f51ec8c804e1 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -1521,7 +1521,7 @@ func (s *MethodTestSuite) TestTemplate() { ID: t1.ID, }).Asserts(t1, policy.ActionUpdate) })) - s.Run("UpdateTemplateVersionAITaskAndExternalAgentByJobID", s.Subtest(func(db database.Store, check *expects) { + s.Run("UpdateTemplateVersionFlagsByJobID", s.Subtest(func(db database.Store, check *expects) { dbtestutil.DisableForeignKeysAndTriggers(s.T(), db) o := dbgen.Organization(s.T(), db, database.Organization{}) u := dbgen.User(s.T(), db, database.User{}) @@ -1534,7 +1534,7 @@ func (s *MethodTestSuite) TestTemplate() { JobID: job.ID, TemplateID: uuid.NullUUID{UUID: t.ID, Valid: true}, }) - check.Args(database.UpdateTemplateVersionAITaskAndExternalAgentByJobIDParams{ + check.Args(database.UpdateTemplateVersionFlagsByJobIDParams{ JobID: job.ID, HasAITask: sql.NullBool{Bool: true, Valid: true}, HasExternalAgent: sql.NullBool{Bool: true, Valid: true}, @@ -3228,7 +3228,7 @@ func (s *MethodTestSuite) TestWorkspace() { Deadline: b.Deadline, }).Asserts(w, policy.ActionUpdate) })) - s.Run("UpdateWorkspaceBuildAITaskAndExternalAgentByID", s.Subtest(func(db database.Store, check *expects) { + s.Run("UpdateWorkspaceBuildFlagsByID", s.Subtest(func(db database.Store, check *expects) { u := dbgen.User(s.T(), db, database.User{}) o := dbgen.Organization(s.T(), db, database.Organization{}) tpl := dbgen.Template(s.T(), db, database.Template{ @@ -3256,7 +3256,7 @@ func (s *MethodTestSuite) TestWorkspace() { res := dbgen.WorkspaceResource(s.T(), db, database.WorkspaceResource{JobID: b.JobID}) agt := dbgen.WorkspaceAgent(s.T(), db, database.WorkspaceAgent{ResourceID: res.ID}) app := dbgen.WorkspaceApp(s.T(), db, database.WorkspaceApp{AgentID: agt.ID}) - check.Args(database.UpdateWorkspaceBuildAITaskAndExternalAgentByIDParams{ + check.Args(database.UpdateWorkspaceBuildFlagsByIDParams{ ID: b.ID, HasAITask: sql.NullBool{Bool: true, Valid: true}, HasExternalAgent: sql.NullBool{Bool: true, Valid: true}, diff --git a/coderd/database/dbgen/dbgen.go b/coderd/database/dbgen/dbgen.go index 6e450bc4ee09c..e776c3d9c388f 100644 --- a/coderd/database/dbgen/dbgen.go +++ b/coderd/database/dbgen/dbgen.go @@ -472,7 +472,7 @@ func WorkspaceBuild(t testing.TB, db database.Store, orig database.WorkspaceBuil } if hasAITask.Valid || hasExternalAgent.Valid { - require.NoError(t, db.UpdateWorkspaceBuildAITaskAndExternalAgentByID(genCtx, database.UpdateWorkspaceBuildAITaskAndExternalAgentByIDParams{ + require.NoError(t, db.UpdateWorkspaceBuildFlagsByID(genCtx, database.UpdateWorkspaceBuildFlagsByIDParams{ ID: buildID, HasAITask: hasAITask, HasExternalAgent: hasExternalAgent, @@ -1052,7 +1052,7 @@ func TemplateVersion(t testing.TB, db database.Store, orig database.TemplateVers } if hasAITask.Valid || hasExternalAgent.Valid { - require.NoError(t, db.UpdateTemplateVersionAITaskAndExternalAgentByJobID(genCtx, database.UpdateTemplateVersionAITaskAndExternalAgentByJobIDParams{ + require.NoError(t, db.UpdateTemplateVersionFlagsByJobID(genCtx, database.UpdateTemplateVersionFlagsByJobIDParams{ JobID: jobID, HasAITask: hasAITask, HasExternalAgent: hasExternalAgent, diff --git a/coderd/database/dbmetrics/querymetrics.go b/coderd/database/dbmetrics/querymetrics.go index 77321c23717b6..65ec7e864a9e2 100644 --- a/coderd/database/dbmetrics/querymetrics.go +++ b/coderd/database/dbmetrics/querymetrics.go @@ -2903,13 +2903,6 @@ func (m queryMetricsStore) UpdateTemplateScheduleByID(ctx context.Context, arg d return err } -func (m queryMetricsStore) UpdateTemplateVersionAITaskAndExternalAgentByJobID(ctx context.Context, arg database.UpdateTemplateVersionAITaskAndExternalAgentByJobIDParams) error { - start := time.Now() - r0 := m.s.UpdateTemplateVersionAITaskAndExternalAgentByJobID(ctx, arg) - m.queryLatencies.WithLabelValues("UpdateTemplateVersionAITaskAndExternalAgentByJobID").Observe(time.Since(start).Seconds()) - return r0 -} - func (m queryMetricsStore) UpdateTemplateVersionByID(ctx context.Context, arg database.UpdateTemplateVersionByIDParams) error { start := time.Now() err := m.s.UpdateTemplateVersionByID(ctx, arg) @@ -2931,6 +2924,13 @@ func (m queryMetricsStore) UpdateTemplateVersionExternalAuthProvidersByJobID(ctx return err } +func (m queryMetricsStore) UpdateTemplateVersionFlagsByJobID(ctx context.Context, arg database.UpdateTemplateVersionFlagsByJobIDParams) error { + start := time.Now() + r0 := m.s.UpdateTemplateVersionFlagsByJobID(ctx, arg) + m.queryLatencies.WithLabelValues("UpdateTemplateVersionFlagsByJobID").Observe(time.Since(start).Seconds()) + return r0 +} + func (m queryMetricsStore) UpdateTemplateWorkspacesLastUsedAt(ctx context.Context, arg database.UpdateTemplateWorkspacesLastUsedAtParams) error { start := time.Now() r0 := m.s.UpdateTemplateWorkspacesLastUsedAt(ctx, arg) @@ -3127,13 +3127,6 @@ func (m queryMetricsStore) UpdateWorkspaceAutostart(ctx context.Context, arg dat return err } -func (m queryMetricsStore) UpdateWorkspaceBuildAITaskAndExternalAgentByID(ctx context.Context, arg database.UpdateWorkspaceBuildAITaskAndExternalAgentByIDParams) error { - start := time.Now() - r0 := m.s.UpdateWorkspaceBuildAITaskAndExternalAgentByID(ctx, arg) - m.queryLatencies.WithLabelValues("UpdateWorkspaceBuildAITaskAndExternalAgentByID").Observe(time.Since(start).Seconds()) - return r0 -} - func (m queryMetricsStore) UpdateWorkspaceBuildCostByID(ctx context.Context, arg database.UpdateWorkspaceBuildCostByIDParams) error { start := time.Now() err := m.s.UpdateWorkspaceBuildCostByID(ctx, arg) @@ -3148,6 +3141,13 @@ func (m queryMetricsStore) UpdateWorkspaceBuildDeadlineByID(ctx context.Context, return r0 } +func (m queryMetricsStore) UpdateWorkspaceBuildFlagsByID(ctx context.Context, arg database.UpdateWorkspaceBuildFlagsByIDParams) error { + start := time.Now() + r0 := m.s.UpdateWorkspaceBuildFlagsByID(ctx, arg) + m.queryLatencies.WithLabelValues("UpdateWorkspaceBuildFlagsByID").Observe(time.Since(start).Seconds()) + return r0 +} + func (m queryMetricsStore) UpdateWorkspaceBuildProvisionerStateByID(ctx context.Context, arg database.UpdateWorkspaceBuildProvisionerStateByIDParams) error { start := time.Now() r0 := m.s.UpdateWorkspaceBuildProvisionerStateByID(ctx, arg) diff --git a/coderd/database/dbmock/dbmock.go b/coderd/database/dbmock/dbmock.go index 9e32034c04025..64a2cf75fd5f2 100644 --- a/coderd/database/dbmock/dbmock.go +++ b/coderd/database/dbmock/dbmock.go @@ -6200,20 +6200,6 @@ func (mr *MockStoreMockRecorder) UpdateTemplateScheduleByID(ctx, arg any) *gomoc return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateTemplateScheduleByID", reflect.TypeOf((*MockStore)(nil).UpdateTemplateScheduleByID), ctx, arg) } -// UpdateTemplateVersionAITaskAndExternalAgentByJobID mocks base method. -func (m *MockStore) UpdateTemplateVersionAITaskAndExternalAgentByJobID(ctx context.Context, arg database.UpdateTemplateVersionAITaskAndExternalAgentByJobIDParams) error { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "UpdateTemplateVersionAITaskAndExternalAgentByJobID", ctx, arg) - ret0, _ := ret[0].(error) - return ret0 -} - -// UpdateTemplateVersionAITaskAndExternalAgentByJobID indicates an expected call of UpdateTemplateVersionAITaskAndExternalAgentByJobID. -func (mr *MockStoreMockRecorder) UpdateTemplateVersionAITaskAndExternalAgentByJobID(ctx, arg any) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateTemplateVersionAITaskAndExternalAgentByJobID", reflect.TypeOf((*MockStore)(nil).UpdateTemplateVersionAITaskAndExternalAgentByJobID), ctx, arg) -} - // UpdateTemplateVersionByID mocks base method. func (m *MockStore) UpdateTemplateVersionByID(ctx context.Context, arg database.UpdateTemplateVersionByIDParams) error { m.ctrl.T.Helper() @@ -6256,6 +6242,20 @@ func (mr *MockStoreMockRecorder) UpdateTemplateVersionExternalAuthProvidersByJob return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateTemplateVersionExternalAuthProvidersByJobID", reflect.TypeOf((*MockStore)(nil).UpdateTemplateVersionExternalAuthProvidersByJobID), ctx, arg) } +// UpdateTemplateVersionFlagsByJobID mocks base method. +func (m *MockStore) UpdateTemplateVersionFlagsByJobID(ctx context.Context, arg database.UpdateTemplateVersionFlagsByJobIDParams) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpdateTemplateVersionFlagsByJobID", ctx, arg) + ret0, _ := ret[0].(error) + return ret0 +} + +// UpdateTemplateVersionFlagsByJobID indicates an expected call of UpdateTemplateVersionFlagsByJobID. +func (mr *MockStoreMockRecorder) UpdateTemplateVersionFlagsByJobID(ctx, arg any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateTemplateVersionFlagsByJobID", reflect.TypeOf((*MockStore)(nil).UpdateTemplateVersionFlagsByJobID), ctx, arg) +} + // UpdateTemplateWorkspacesLastUsedAt mocks base method. func (m *MockStore) UpdateTemplateWorkspacesLastUsedAt(ctx context.Context, arg database.UpdateTemplateWorkspacesLastUsedAtParams) error { m.ctrl.T.Helper() @@ -6661,20 +6661,6 @@ func (mr *MockStoreMockRecorder) UpdateWorkspaceAutostart(ctx, arg any) *gomock. return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateWorkspaceAutostart", reflect.TypeOf((*MockStore)(nil).UpdateWorkspaceAutostart), ctx, arg) } -// UpdateWorkspaceBuildAITaskAndExternalAgentByID mocks base method. -func (m *MockStore) UpdateWorkspaceBuildAITaskAndExternalAgentByID(ctx context.Context, arg database.UpdateWorkspaceBuildAITaskAndExternalAgentByIDParams) error { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "UpdateWorkspaceBuildAITaskAndExternalAgentByID", ctx, arg) - ret0, _ := ret[0].(error) - return ret0 -} - -// UpdateWorkspaceBuildAITaskAndExternalAgentByID indicates an expected call of UpdateWorkspaceBuildAITaskAndExternalAgentByID. -func (mr *MockStoreMockRecorder) UpdateWorkspaceBuildAITaskAndExternalAgentByID(ctx, arg any) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateWorkspaceBuildAITaskAndExternalAgentByID", reflect.TypeOf((*MockStore)(nil).UpdateWorkspaceBuildAITaskAndExternalAgentByID), ctx, arg) -} - // UpdateWorkspaceBuildCostByID mocks base method. func (m *MockStore) UpdateWorkspaceBuildCostByID(ctx context.Context, arg database.UpdateWorkspaceBuildCostByIDParams) error { m.ctrl.T.Helper() @@ -6703,6 +6689,20 @@ func (mr *MockStoreMockRecorder) UpdateWorkspaceBuildDeadlineByID(ctx, arg any) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateWorkspaceBuildDeadlineByID", reflect.TypeOf((*MockStore)(nil).UpdateWorkspaceBuildDeadlineByID), ctx, arg) } +// UpdateWorkspaceBuildFlagsByID mocks base method. +func (m *MockStore) UpdateWorkspaceBuildFlagsByID(ctx context.Context, arg database.UpdateWorkspaceBuildFlagsByIDParams) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpdateWorkspaceBuildFlagsByID", ctx, arg) + ret0, _ := ret[0].(error) + return ret0 +} + +// UpdateWorkspaceBuildFlagsByID indicates an expected call of UpdateWorkspaceBuildFlagsByID. +func (mr *MockStoreMockRecorder) UpdateWorkspaceBuildFlagsByID(ctx, arg any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateWorkspaceBuildFlagsByID", reflect.TypeOf((*MockStore)(nil).UpdateWorkspaceBuildFlagsByID), ctx, arg) +} + // UpdateWorkspaceBuildProvisionerStateByID mocks base method. func (m *MockStore) UpdateWorkspaceBuildProvisionerStateByID(ctx context.Context, arg database.UpdateWorkspaceBuildProvisionerStateByIDParams) error { m.ctrl.T.Helper() diff --git a/coderd/database/querier.go b/coderd/database/querier.go index 9ad918b607dc4..536e70c130d94 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -609,10 +609,10 @@ type sqlcQuerier interface { UpdateTemplateDeletedByID(ctx context.Context, arg UpdateTemplateDeletedByIDParams) error UpdateTemplateMetaByID(ctx context.Context, arg UpdateTemplateMetaByIDParams) error UpdateTemplateScheduleByID(ctx context.Context, arg UpdateTemplateScheduleByIDParams) error - UpdateTemplateVersionAITaskAndExternalAgentByJobID(ctx context.Context, arg UpdateTemplateVersionAITaskAndExternalAgentByJobIDParams) error UpdateTemplateVersionByID(ctx context.Context, arg UpdateTemplateVersionByIDParams) error UpdateTemplateVersionDescriptionByJobID(ctx context.Context, arg UpdateTemplateVersionDescriptionByJobIDParams) error UpdateTemplateVersionExternalAuthProvidersByJobID(ctx context.Context, arg UpdateTemplateVersionExternalAuthProvidersByJobIDParams) error + UpdateTemplateVersionFlagsByJobID(ctx context.Context, arg UpdateTemplateVersionFlagsByJobIDParams) error UpdateTemplateWorkspacesLastUsedAt(ctx context.Context, arg UpdateTemplateWorkspacesLastUsedAtParams) error UpdateUserDeletedByID(ctx context.Context, id uuid.UUID) error UpdateUserGithubComUserID(ctx context.Context, arg UpdateUserGithubComUserIDParams) error @@ -641,9 +641,9 @@ type sqlcQuerier interface { UpdateWorkspaceAppHealthByID(ctx context.Context, arg UpdateWorkspaceAppHealthByIDParams) error UpdateWorkspaceAutomaticUpdates(ctx context.Context, arg UpdateWorkspaceAutomaticUpdatesParams) error UpdateWorkspaceAutostart(ctx context.Context, arg UpdateWorkspaceAutostartParams) error - UpdateWorkspaceBuildAITaskAndExternalAgentByID(ctx context.Context, arg UpdateWorkspaceBuildAITaskAndExternalAgentByIDParams) error UpdateWorkspaceBuildCostByID(ctx context.Context, arg UpdateWorkspaceBuildCostByIDParams) error UpdateWorkspaceBuildDeadlineByID(ctx context.Context, arg UpdateWorkspaceBuildDeadlineByIDParams) error + UpdateWorkspaceBuildFlagsByID(ctx context.Context, arg UpdateWorkspaceBuildFlagsByIDParams) error UpdateWorkspaceBuildProvisionerStateByID(ctx context.Context, arg UpdateWorkspaceBuildProvisionerStateByIDParams) error UpdateWorkspaceDeletedByID(ctx context.Context, arg UpdateWorkspaceDeletedByIDParams) error UpdateWorkspaceDormantDeletingAt(ctx context.Context, arg UpdateWorkspaceDormantDeletingAtParams) (WorkspaceTable, error) diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index d2bca981e8498..cca8c0d8cedfb 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -13156,34 +13156,6 @@ func (q *sqlQuerier) UnarchiveTemplateVersion(ctx context.Context, arg Unarchive return err } -const updateTemplateVersionAITaskAndExternalAgentByJobID = `-- name: UpdateTemplateVersionAITaskAndExternalAgentByJobID :exec -UPDATE - template_versions -SET - has_ai_task = $2, - has_external_agent = $3, - updated_at = $4 -WHERE - job_id = $1 -` - -type UpdateTemplateVersionAITaskAndExternalAgentByJobIDParams struct { - JobID uuid.UUID `db:"job_id" json:"job_id"` - HasAITask sql.NullBool `db:"has_ai_task" json:"has_ai_task"` - HasExternalAgent sql.NullBool `db:"has_external_agent" json:"has_external_agent"` - UpdatedAt time.Time `db:"updated_at" json:"updated_at"` -} - -func (q *sqlQuerier) UpdateTemplateVersionAITaskAndExternalAgentByJobID(ctx context.Context, arg UpdateTemplateVersionAITaskAndExternalAgentByJobIDParams) error { - _, err := q.db.ExecContext(ctx, updateTemplateVersionAITaskAndExternalAgentByJobID, - arg.JobID, - arg.HasAITask, - arg.HasExternalAgent, - arg.UpdatedAt, - ) - return err -} - const updateTemplateVersionByID = `-- name: UpdateTemplateVersionByID :exec UPDATE template_versions @@ -13257,6 +13229,34 @@ func (q *sqlQuerier) UpdateTemplateVersionExternalAuthProvidersByJobID(ctx conte return err } +const updateTemplateVersionFlagsByJobID = `-- name: UpdateTemplateVersionFlagsByJobID :exec +UPDATE + template_versions +SET + has_ai_task = $2, + has_external_agent = $3, + updated_at = $4 +WHERE + job_id = $1 +` + +type UpdateTemplateVersionFlagsByJobIDParams struct { + JobID uuid.UUID `db:"job_id" json:"job_id"` + HasAITask sql.NullBool `db:"has_ai_task" json:"has_ai_task"` + HasExternalAgent sql.NullBool `db:"has_external_agent" json:"has_external_agent"` + UpdatedAt time.Time `db:"updated_at" json:"updated_at"` +} + +func (q *sqlQuerier) UpdateTemplateVersionFlagsByJobID(ctx context.Context, arg UpdateTemplateVersionFlagsByJobIDParams) error { + _, err := q.db.ExecContext(ctx, updateTemplateVersionFlagsByJobID, + arg.JobID, + arg.HasAITask, + arg.HasExternalAgent, + arg.UpdatedAt, + ) + return err +} + const getTemplateVersionTerraformValues = `-- name: GetTemplateVersionTerraformValues :one SELECT template_version_terraform_values.template_version_id, template_version_terraform_values.updated_at, template_version_terraform_values.cached_plan, template_version_terraform_values.cached_module_files, template_version_terraform_values.provisionerd_version @@ -19211,36 +19211,6 @@ func (q *sqlQuerier) InsertWorkspaceBuild(ctx context.Context, arg InsertWorkspa return err } -const updateWorkspaceBuildAITaskAndExternalAgentByID = `-- name: UpdateWorkspaceBuildAITaskAndExternalAgentByID :exec -UPDATE - workspace_builds -SET - has_ai_task = $1, - ai_task_sidebar_app_id = $2, - has_external_agent = $3, - updated_at = $4::timestamptz -WHERE id = $5::uuid -` - -type UpdateWorkspaceBuildAITaskAndExternalAgentByIDParams struct { - HasAITask sql.NullBool `db:"has_ai_task" json:"has_ai_task"` - SidebarAppID uuid.NullUUID `db:"sidebar_app_id" json:"sidebar_app_id"` - HasExternalAgent sql.NullBool `db:"has_external_agent" json:"has_external_agent"` - UpdatedAt time.Time `db:"updated_at" json:"updated_at"` - ID uuid.UUID `db:"id" json:"id"` -} - -func (q *sqlQuerier) UpdateWorkspaceBuildAITaskAndExternalAgentByID(ctx context.Context, arg UpdateWorkspaceBuildAITaskAndExternalAgentByIDParams) error { - _, err := q.db.ExecContext(ctx, updateWorkspaceBuildAITaskAndExternalAgentByID, - arg.HasAITask, - arg.SidebarAppID, - arg.HasExternalAgent, - arg.UpdatedAt, - arg.ID, - ) - return err -} - const updateWorkspaceBuildCostByID = `-- name: UpdateWorkspaceBuildCostByID :exec UPDATE workspace_builds @@ -19287,6 +19257,36 @@ func (q *sqlQuerier) UpdateWorkspaceBuildDeadlineByID(ctx context.Context, arg U return err } +const updateWorkspaceBuildFlagsByID = `-- name: UpdateWorkspaceBuildFlagsByID :exec +UPDATE + workspace_builds +SET + has_ai_task = $1, + ai_task_sidebar_app_id = $2, + has_external_agent = $3, + updated_at = $4::timestamptz +WHERE id = $5::uuid +` + +type UpdateWorkspaceBuildFlagsByIDParams struct { + HasAITask sql.NullBool `db:"has_ai_task" json:"has_ai_task"` + SidebarAppID uuid.NullUUID `db:"sidebar_app_id" json:"sidebar_app_id"` + HasExternalAgent sql.NullBool `db:"has_external_agent" json:"has_external_agent"` + UpdatedAt time.Time `db:"updated_at" json:"updated_at"` + ID uuid.UUID `db:"id" json:"id"` +} + +func (q *sqlQuerier) UpdateWorkspaceBuildFlagsByID(ctx context.Context, arg UpdateWorkspaceBuildFlagsByIDParams) error { + _, err := q.db.ExecContext(ctx, updateWorkspaceBuildFlagsByID, + arg.HasAITask, + arg.SidebarAppID, + arg.HasExternalAgent, + arg.UpdatedAt, + arg.ID, + ) + return err +} + const updateWorkspaceBuildProvisionerStateByID = `-- name: UpdateWorkspaceBuildProvisionerStateByID :exec UPDATE workspace_builds diff --git a/coderd/database/queries/templateversions.sql b/coderd/database/queries/templateversions.sql index 8b1a6227ee4a7..e68383aa0632e 100644 --- a/coderd/database/queries/templateversions.sql +++ b/coderd/database/queries/templateversions.sql @@ -226,7 +226,7 @@ WHERE template_versions.id IN (archived_versions.id) RETURNING template_versions.id; --- name: UpdateTemplateVersionAITaskAndExternalAgentByJobID :exec +-- name: UpdateTemplateVersionFlagsByJobID :exec UPDATE template_versions SET diff --git a/coderd/database/queries/workspacebuilds.sql b/coderd/database/queries/workspacebuilds.sql index b7a02236e408a..0cd414936b9f7 100644 --- a/coderd/database/queries/workspacebuilds.sql +++ b/coderd/database/queries/workspacebuilds.sql @@ -245,7 +245,7 @@ WHERE ORDER BY tv.name ASC, wb.build_number DESC; --- name: UpdateWorkspaceBuildAITaskAndExternalAgentByID :exec +-- name: UpdateWorkspaceBuildFlagsByID :exec UPDATE workspace_builds SET diff --git a/coderd/init_script_test.go b/coderd/init_script_test.go deleted file mode 100644 index b499f89ecccce..0000000000000 --- a/coderd/init_script_test.go +++ /dev/null @@ -1,31 +0,0 @@ -package coderd_test - -import ( - "context" - "fmt" - "testing" - - "github.com/stretchr/testify/require" - - "github.com/coder/coder/v2/coderd/coderdtest" -) - -func TestInitScript(t *testing.T) { - t.Parallel() - - t.Run("OK", func(t *testing.T) { - t.Parallel() - client := coderdtest.New(t, nil) - script, err := client.InitScript(context.Background(), "windows", "amd64") - require.NoError(t, err) - require.NotEmpty(t, script) - }) - - t.Run("BadRequest", func(t *testing.T) { - t.Parallel() - client := coderdtest.New(t, nil) - _, err := client.InitScript(context.Background(), "darwin", "armv7") - require.Error(t, err) - fmt.Printf("err: %+v\n", err) - }) -} diff --git a/coderd/init_script.go b/coderd/initscript.go similarity index 81% rename from coderd/init_script.go rename to coderd/initscript.go index c9ab7da6171a8..14769ce74179e 100644 --- a/coderd/init_script.go +++ b/coderd/initscript.go @@ -7,6 +7,8 @@ import ( "github.com/go-chi/chi/v5" + "github.com/coder/coder/v2/coderd/httpapi" + "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/provisionersdk" ) @@ -24,7 +26,9 @@ func (api *API) initScript(rw http.ResponseWriter, r *http.Request) { script, exists := provisionersdk.AgentScriptEnv()[fmt.Sprintf("CODER_AGENT_SCRIPT_%s_%s", os, arch)] if !exists { - rw.WriteHeader(http.StatusBadRequest) + httpapi.Write(r.Context(), rw, http.StatusBadRequest, codersdk.Response{ + Message: fmt.Sprintf("Unknown os/arch: %s/%s", os, arch), + }) return } script = strings.ReplaceAll(script, "${ACCESS_URL}", api.AccessURL.String()+"/") diff --git a/coderd/initscript_test.go b/coderd/initscript_test.go new file mode 100644 index 0000000000000..c47162ff3b6e9 --- /dev/null +++ b/coderd/initscript_test.go @@ -0,0 +1,45 @@ +package coderd_test + +import ( + "context" + "net/http" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/coder/coder/v2/coderd/coderdtest" + "github.com/coder/coder/v2/codersdk" +) + +func TestInitScript(t *testing.T) { + t.Parallel() + + t.Run("OK Windows", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, nil) + script, err := client.InitScript(context.Background(), "windows", "amd64") + require.NoError(t, err) + require.NotEmpty(t, script) + require.Contains(t, script, "$env:CODER_AGENT_AUTH = \"token\"") + }) + + t.Run("OK Linux", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, nil) + script, err := client.InitScript(context.Background(), "linux", "amd64") + require.NoError(t, err) + require.NotEmpty(t, script) + require.Contains(t, script, "export CODER_AGENT_AUTH=\"token\"") + }) + + t.Run("BadRequest", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, nil) + _, err := client.InitScript(context.Background(), "darwin", "armv7") + require.Error(t, err) + var apiErr *codersdk.Error + require.ErrorAs(t, err, &apiErr) + require.Equal(t, http.StatusBadRequest, apiErr.StatusCode()) + require.Equal(t, "Unknown os/arch: darwin/armv7", apiErr.Message) + }) +} diff --git a/coderd/provisionerdserver/provisionerdserver.go b/coderd/provisionerdserver/provisionerdserver.go index 8a9b2c2d54818..c81c4de9866a0 100644 --- a/coderd/provisionerdserver/provisionerdserver.go +++ b/coderd/provisionerdserver/provisionerdserver.go @@ -1720,7 +1720,7 @@ 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.UpdateTemplateVersionAITaskAndExternalAgentByJobID(ctx, database.UpdateTemplateVersionAITaskAndExternalAgentByJobIDParams{ + err = db.UpdateTemplateVersionFlagsByJobID(ctx, database.UpdateTemplateVersionFlagsByJobIDParams{ JobID: jobID, HasAITask: sql.NullBool{ Bool: jobType.TemplateImport.HasAiTasks, @@ -1974,7 +1974,7 @@ func (s *server) completeWorkspaceBuildJob(ctx context.Context, job database.Pro // Regardless of whether there is an AI task or not, update the field to indicate one way or the other since it // always defaults to nil. ONLY if has_ai_task=true MUST ai_task_sidebar_app_id be set. - err = db.UpdateWorkspaceBuildAITaskAndExternalAgentByID(ctx, database.UpdateWorkspaceBuildAITaskAndExternalAgentByIDParams{ + err = db.UpdateWorkspaceBuildFlagsByID(ctx, database.UpdateWorkspaceBuildFlagsByIDParams{ ID: workspaceBuild.ID, HasAITask: sql.NullBool{ Bool: hasAITask, diff --git a/coderd/workspaceagents_test.go b/coderd/workspaceagents_test.go index a421674131857..2e7c83bfd541e 100644 --- a/coderd/workspaceagents_test.go +++ b/coderd/workspaceagents_test.go @@ -3062,7 +3062,30 @@ func TestWorkspaceExternalAgentCredentials(t *testing.T) { client, db := coderdtest.NewWithDatabase(t, nil) user := coderdtest.CreateFirstUser(t, client) - t.Run("Success", func(t *testing.T) { + t.Run("Success - linux", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitShort) + + r := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{ + OrganizationID: user.OrganizationID, + OwnerID: user.UserID, + }).WithAgent(func(a []*proto.Agent) []*proto.Agent { + a[0].Name = "test-agent" + a[0].OperatingSystem = "linux" + a[0].Architecture = "amd64" + return a + }).Do() + + credentials, err := client.WorkspaceExternalAgentCredentials( + ctx, r.Workspace.ID, "test-agent") + require.NoError(t, err) + + require.Equal(t, r.AgentToken, credentials.AgentToken) + expectedCommand := fmt.Sprintf("CODER_AGENT_TOKEN=%q curl -fsSL \"%s/api/v2/init-script/linux/amd64\" | sh", r.AgentToken, client.URL) + require.Equal(t, expectedCommand, credentials.Command) + }) + + t.Run("Success - windows", func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitShort) @@ -3071,6 +3094,8 @@ func TestWorkspaceExternalAgentCredentials(t *testing.T) { OwnerID: user.UserID, }).WithAgent(func(a []*proto.Agent) []*proto.Agent { a[0].Name = "test-agent" + a[0].OperatingSystem = "windows" + a[0].Architecture = "amd64" return a }).Do() @@ -3079,6 +3104,8 @@ func TestWorkspaceExternalAgentCredentials(t *testing.T) { require.NoError(t, err) require.Equal(t, r.AgentToken, credentials.AgentToken) + expectedCommand := fmt.Sprintf("$env:CODER_AGENT_TOKEN=%q; iwr -useb \"%s/api/v2/init-script/windows/amd64\" | iex", r.AgentToken, client.URL) + require.Equal(t, expectedCommand, credentials.Command) }) t.Run("WithInstanceID - should return 404", func(t *testing.T) { @@ -3098,6 +3125,8 @@ func TestWorkspaceExternalAgentCredentials(t *testing.T) { _, err := client.WorkspaceExternalAgentCredentials(ctx, r.Workspace.ID, "test-agent") require.Error(t, err) - require.Contains(t, err.Error(), "External agent is authenticated with an instance ID.") + var apiErr *codersdk.Error + require.ErrorAs(t, err, &apiErr) + require.Equal(t, "External agent is authenticated with an instance ID.", apiErr.Message) }) } diff --git a/codersdk/init_script.go b/codersdk/initscript.go similarity index 100% rename from codersdk/init_script.go rename to codersdk/initscript.go From a75c1f4625070a3f597cc26ded1690a19d66fbf5 Mon Sep 17 00:00:00 2001 From: Kacper Sawicki Date: Fri, 8 Aug 2025 15:00:08 +0000 Subject: [PATCH 28/28] make gen --- coderd/database/dbauthz/dbauthz.go | 80 +++++++++++++++--------------- 1 file changed, 40 insertions(+), 40 deletions(-) diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index 93c1b39996875..4f68e510f19af 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -1225,46 +1225,6 @@ func (q *querier) authorizeProvisionerJob(ctx context.Context, job database.Prov return nil } -func (q *querier) UpdateTemplateVersionFlagsByJobID(ctx context.Context, arg database.UpdateTemplateVersionFlagsByJobIDParams) error { - // An actor is allowed to update the template version ai task and external agent 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.UpdateTemplateVersionFlagsByJobID(ctx, arg) -} - -func (q *querier) UpdateWorkspaceBuildFlagsByID(ctx context.Context, arg database.UpdateWorkspaceBuildFlagsByIDParams) error { - build, err := q.db.GetWorkspaceBuildByID(ctx, arg.ID) - if err != nil { - return err - } - - workspace, err := q.db.GetWorkspaceByID(ctx, build.WorkspaceID) - if err != nil { - return err - } - - err = q.authorizeContext(ctx, policy.ActionUpdate, workspace.RBACObject()) - if err != nil { - return err - } - return q.db.UpdateWorkspaceBuildFlagsByID(ctx, arg) -} - func (q *querier) AcquireLock(ctx context.Context, id int64) error { return q.db.AcquireLock(ctx, id) } @@ -4797,6 +4757,28 @@ func (q *querier) UpdateTemplateVersionExternalAuthProvidersByJobID(ctx context. return q.db.UpdateTemplateVersionExternalAuthProvidersByJobID(ctx, arg) } +func (q *querier) UpdateTemplateVersionFlagsByJobID(ctx context.Context, arg database.UpdateTemplateVersionFlagsByJobIDParams) error { + // An actor is allowed to update the template version ai task and external agent 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.UpdateTemplateVersionFlagsByJobID(ctx, arg) +} + func (q *querier) UpdateTemplateWorkspacesLastUsedAt(ctx context.Context, arg database.UpdateTemplateWorkspacesLastUsedAtParams) error { fetch := func(ctx context.Context, arg database.UpdateTemplateWorkspacesLastUsedAtParams) (database.Template, error) { return q.db.GetTemplateByID(ctx, arg.TemplateID) @@ -5138,6 +5120,24 @@ func (q *querier) UpdateWorkspaceBuildDeadlineByID(ctx context.Context, arg data return q.db.UpdateWorkspaceBuildDeadlineByID(ctx, arg) } +func (q *querier) UpdateWorkspaceBuildFlagsByID(ctx context.Context, arg database.UpdateWorkspaceBuildFlagsByIDParams) error { + build, err := q.db.GetWorkspaceBuildByID(ctx, arg.ID) + if err != nil { + return err + } + + workspace, err := q.db.GetWorkspaceByID(ctx, build.WorkspaceID) + if err != nil { + return err + } + + err = q.authorizeContext(ctx, policy.ActionUpdate, workspace.RBACObject()) + if err != nil { + return err + } + return q.db.UpdateWorkspaceBuildFlagsByID(ctx, arg) +} + func (q *querier) UpdateWorkspaceBuildProvisionerStateByID(ctx context.Context, arg database.UpdateWorkspaceBuildProvisionerStateByIDParams) error { if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceSystem); err != nil { return err