From 60dc3308375c36f098fc052a1b7d37cc9baec767 Mon Sep 17 00:00:00 2001 From: Kacper Sawicki Date: Mon, 11 Aug 2025 11:08:33 +0000 Subject: [PATCH 01/14] feat: add support for external agents to API's and provisioner --- cli/testdata/coder_list_--output_json.golden | 3 +- ...oder_provisioner_list_--output_json.golden | 2 +- coderd/apidoc/docs.go | 91 +++++- coderd/apidoc/swagger.json | 83 +++++- coderd/coderd.go | 6 + coderd/coderdtest/swaggerparser.go | 6 +- coderd/initscript.go | 40 +++ coderd/initscript_test.go | 45 +++ .../provisionerdserver/provisionerdserver.go | 28 +- coderd/searchquery/search.go | 20 +- coderd/searchquery/search_test.go | 60 ++++ coderd/templates_test.go | 56 ++++ coderd/workspaceagents.go | 68 +++++ coderd/workspaceagents_test.go | 74 +++++ coderd/workspacebuilds.go | 6 + coderd/workspaces.go | 2 +- codersdk/initscript.go | 28 ++ codersdk/workspacebuilds.go | 1 + codersdk/workspaces.go | 20 ++ docs/manifest.json | 16 +- docs/reference/api/agents.md | 39 +++ docs/reference/api/builds.md | 6 + docs/reference/api/initscript.md | 26 ++ docs/reference/api/schemas.md | 20 ++ docs/reference/api/workspaces.md | 16 +- provisioner/terraform/executor.go | 1 + provisioner/terraform/provision_test.go | 26 ++ provisioner/terraform/resources.go | 19 +- 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 +- provisionerd/proto/provisionerd.pb.go | 17 +- provisionerd/proto/provisionerd.proto | 1 + provisionerd/proto/version.go | 5 +- provisionerd/runner/runner.go | 7 +- provisionersdk/proto/provisioner.pb.go | 19 +- provisionersdk/proto/provisioner.proto | 1 + 41 files changed, 1315 insertions(+), 54 deletions(-) create mode 100644 coderd/initscript.go create mode 100644 coderd/initscript_test.go create mode 100644 codersdk/initscript.go create mode 100644 docs/reference/api/initscript.md 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/testdata/coder_list_--output_json.golden b/cli/testdata/coder_list_--output_json.golden index ba560a39f59d7..82b73f7b24989 100644 --- a/cli/testdata/coder_list_--output_json.golden +++ b/cli/testdata/coder_list_--output_json.golden @@ -70,7 +70,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/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/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index e7830ef285836..963c95d10efbc 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -1280,6 +1280,39 @@ const docTemplate = `{ } } }, + "/init-script/{os}/{arch}": { + "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": "path", + "required": true + }, + { + "type": "string", + "description": "Architecture", + "name": "arch", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "Success" + } + } + } + }, "/insights/daus": { "get": { "security": [ @@ -9835,7 +9868,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" }, @@ -10271,6 +10304,48 @@ const docTemplate = `{ } } }, + "/workspaces/{workspace}/external-agent/{agent}/credentials": { + "get": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "Agents" + ], + "summary": "Get workspace 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.ExternalAgentCredentials" + } + } + } + } + }, "/workspaces/{workspace}/favorite": { "put": { "security": [ @@ -12901,6 +12976,17 @@ const docTemplate = `{ "ExperimentWorkspaceSharing" ] }, + "codersdk.ExternalAgentCredentials": { + "type": "object", + "properties": { + "agent_token": { + "type": "string" + }, + "command": { + "type": "string" + } + } + }, "codersdk.ExternalAuth": { "type": "object", "properties": { @@ -18675,6 +18761,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 ecb04b352017a..c5d6297aada1c 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -1108,6 +1108,35 @@ } } }, + "/init-script/{os}/{arch}": { + "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": "path", + "required": true + }, + { + "type": "string", + "description": "Architecture", + "name": "arch", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "Success" + } + } + } + }, "/insights/daus": { "get": { "security": [ @@ -8693,7 +8722,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" }, @@ -9085,6 +9114,44 @@ } } }, + "/workspaces/{workspace}/external-agent/{agent}/credentials": { + "get": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "produces": ["application/json"], + "tags": ["Agents"], + "summary": "Get workspace 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.ExternalAgentCredentials" + } + } + } + } + }, "/workspaces/{workspace}/favorite": { "put": { "security": [ @@ -11563,6 +11630,17 @@ "ExperimentWorkspaceSharing" ] }, + "codersdk.ExternalAgentCredentials": { + "type": "object", + "properties": { + "agent_token": { + "type": "string" + }, + "command": { + "type": "string" + } + } + }, "codersdk.ExternalAuth": { "type": "object", "properties": { @@ -17079,6 +17157,9 @@ "has_ai_task": { "type": "boolean" }, + "has_external_agent": { + "type": "boolean" + }, "id": { "type": "string", "format": "uuid" diff --git a/coderd/coderd.go b/coderd/coderd.go index 2aa30c9d7a45c..9ac574ff81358 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -1430,6 +1430,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}/credentials", api.workspaceExternalAgentCredentials) + }) r.Get("/timings", api.workspaceTimings) r.Route("/acl", func(r chi.Router) { r.Use( @@ -1566,6 +1569,9 @@ func New(options *Options) *API { r.Use(apiKeyMiddleware) r.Get("/", api.tailnetRPCConn) }) + r.Route("/init-script", func(r chi.Router) { + r.Get("/{os}/{arch}", api.initScript) + }) }) if options.SwaggerEndpoint { 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/initscript.go b/coderd/initscript.go new file mode 100644 index 0000000000000..14769ce74179e --- /dev/null +++ b/coderd/initscript.go @@ -0,0 +1,40 @@ +package coderd + +import ( + "fmt" + "net/http" + "strings" + + "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" +) + +// @Summary Get agent init script +// @ID get-agent-init-script +// @Produce text/plain +// @Tags InitScript +// @Param os path string true "Operating system" +// @Param arch path string true "Architecture" +// @Success 200 "Success" +// @Router /init-script/{os}/{arch} [get] +func (api *API) initScript(rw http.ResponseWriter, r *http.Request) { + os := strings.ToLower(chi.URLParam(r, "os")) + arch := strings.ToLower(chi.URLParam(r, "arch")) + + script, exists := provisionersdk.AgentScriptEnv()[fmt.Sprintf("CODER_AGENT_SCRIPT_%s_%s", os, arch)] + if !exists { + 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()+"/") + 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/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 950e55413a662..f0091ca63ed5a 100644 --- a/coderd/provisionerdserver/provisionerdserver.go +++ b/coderd/provisionerdserver/provisionerdserver.go @@ -1733,11 +1733,14 @@ func (s *server) completeTemplateImportJob(ctx context.Context, job database.Pro Bool: jobType.TemplateImport.HasAiTasks, Valid: true, }, - HasExternalAgent: sql.NullBool{}, - UpdatedAt: now, + HasExternalAgent: sql.NullBool{ + Bool: jobType.TemplateImport.HasExternalAgents, + Valid: true, + }, + UpdatedAt: now, }) if err != nil { - return xerrors.Errorf("update template version external auth providers: %w", err) + return xerrors.Errorf("update template version ai task and external agent: %w", err) } // Process terraform values @@ -2027,6 +2030,14 @@ func (s *server) completeWorkspaceBuildJob(ctx context.Context, job database.Pro sidebarAppID = uuid.NullUUID{} } + hasExternalAgent := false + for _, resource := range jobType.WorkspaceBuild.Resources { + if resource.Type == "coder_external_agent" { + hasExternalAgent = true + break + } + } + // 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. if err := db.UpdateWorkspaceBuildFlagsByID(ctx, database.UpdateWorkspaceBuildFlagsByIDParams{ @@ -2035,11 +2046,14 @@ func (s *server) completeWorkspaceBuildJob(ctx context.Context, job database.Pro Bool: hasAITask, Valid: true, }, - HasExternalAgent: sql.NullBool{}, - SidebarAppID: sidebarAppID, - UpdatedAt: now, + HasExternalAgent: sql.NullBool{ + Bool: hasExternalAgent, + Valid: true, + }, + SidebarAppID: sidebarAppID, + UpdatedAt: now, }); err != nil { - return xerrors.Errorf("update workspace build ai tasks 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/coderd/searchquery/search.go b/coderd/searchquery/search.go index cbaaa74a848eb..e70d402d17c1e 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,15 +278,16 @@ 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"), } if filter.AuthorUsername == codersdk.Me { diff --git a/coderd/searchquery/search_test.go b/coderd/searchquery/search_test.go index 5c45274668b25..36fc06e2d1479 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 { @@ -689,6 +719,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, + }, + }, + }, { Name: "MyTemplates", Query: "author:me", diff --git a/coderd/templates_test.go b/coderd/templates_test.go index 325de6a18c8e3..f02d861ac9928 100644 --- a/coderd/templates_test.go +++ b/coderd/templates_test.go @@ -2015,3 +2015,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/coderd/workspaceagents.go b/coderd/workspaceagents.go index f2ee1ac18e823..945eca755270f 100644 --- a/coderd/workspaceagents.go +++ b/coderd/workspaceagents.go @@ -2189,3 +2189,71 @@ func convertWorkspaceAgentLog(logEntry database.WorkspaceAgentLog) codersdk.Work SourceID: logEntry.LogSourceID, } } + +// @Summary Get workspace 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.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") + + 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 + } + + 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.StatusOK, codersdk.ExternalAgentCredentials{ + AgentToken: agent.AuthToken.String(), + Command: command, + }) +} diff --git a/coderd/workspaceagents_test.go b/coderd/workspaceagents_test.go index 1855ed8a7e8fc..c95d2f15bf479 100644 --- a/coderd/workspaceagents_test.go +++ b/coderd/workspaceagents_test.go @@ -3136,3 +3136,77 @@ 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 - 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) + + 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 = "windows" + 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("$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) { + 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) + 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/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/coderd/workspaces.go b/coderd/workspaces.go index 99ca6e03a5201..85cd3086c71df 100644 --- a/coderd/workspaces.go +++ b/coderd/workspaces.go @@ -138,7 +138,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/initscript.go b/codersdk/initscript.go new file mode 100644 index 0000000000000..d1adbf79460f0 --- /dev/null +++ b/codersdk/initscript.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 +} 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/codersdk/workspaces.go b/codersdk/workspaces.go index 13cb778ab0ae0..39d52325df448 100644 --- a/codersdk/workspaces.go +++ b/codersdk/workspaces.go @@ -689,3 +689,23 @@ func (c *Client) UpdateWorkspaceACL(ctx context.Context, workspaceID uuid.UUID, } return nil } + +// 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"` +} + +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 { + return ExternalAgentCredentials{}, err + } + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + return ExternalAgentCredentials{}, ReadBodyAsError(res) + } + var credentials ExternalAgentCredentials + return credentials, json.NewDecoder(res.Body).Decode(&credentials) +} diff --git a/docs/manifest.json b/docs/manifest.json index 6e943aa56f697..262992388af10 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", @@ -714,8 +702,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" }, { diff --git a/docs/reference/api/agents.md b/docs/reference/api/agents.md index 54e9b0e6ad628..e72117e96e350 100644 --- a/docs/reference/api/agents.md +++ b/docs/reference/api/agents.md @@ -1238,3 +1238,42 @@ Status Code **200** | `level` | `error` | To perform this operation, you must be authenticated. [Learn more](authentication.md). + +## Get workspace external agent credentials + +### Code samples + +```shell +# Example request using curl +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}/credentials` + +### 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", + "command": "string" +} +``` + +### Responses + +| 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/builds.md b/docs/reference/api/builds.md index a465575baeaa3..526f5bfd25ff1 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", @@ -271,6 +272,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", @@ -998,6 +1000,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", @@ -1309,6 +1312,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", @@ -1528,6 +1532,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 | | | @@ -1802,6 +1807,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/initscript.md b/docs/reference/api/initscript.md new file mode 100644 index 0000000000000..ecd8c8008a6a4 --- /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/{os}/{arch} + +``` + +`GET /init-script/{os}/{arch}` + +### Parameters + +| Name | In | Type | Required | Description | +|--------|------|--------|----------|------------------| +| `os` | path | string | true | Operating system | +| `arch` | path | string | true | 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 dade031c61bcf..de5b6c12ae7ae 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -3322,6 +3322,22 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o | `mcp-server-http` | | `workspace-sharing` | +## codersdk.ExternalAgentCredentials + +```json +{ + "agent_token": "string", + "command": "string" +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +|---------------|--------|----------|--------------|-------------| +| `agent_token` | string | false | | | +| `command` | string | false | | | + ## codersdk.ExternalAuth ```json @@ -8813,6 +8829,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", @@ -9923,6 +9940,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", @@ -10132,6 +10150,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 | | | @@ -10671,6 +10690,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 70338fdeb1814..4f201bd139d21 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", @@ -376,6 +377,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", @@ -689,6 +691,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", @@ -930,11 +933,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 @@ -980,6 +983,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", @@ -1252,6 +1256,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", @@ -1699,6 +1704,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/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/provision_test.go b/provisioner/terraform/provision_test.go index d067965997308..90a34e6d03a8c 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" { + agent_id = "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.go b/provisioner/terraform/resources.go index 9642751e7466a..3dcead074c22a 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. @@ -1065,6 +1080,7 @@ func ConvertState(ctx context.Context, modules []*tfjson.StateModule, rawGraph s ExternalAuthProviders: externalAuthProviders, HasAITasks: hasAITasks, AITasks: aiTasks, + HasExternalAgents: hasExternalAgentResources(graph), }, nil } @@ -1252,7 +1268,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/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/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/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. 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 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, diff --git a/provisionersdk/proto/provisioner.proto b/provisionersdk/proto/provisioner.proto index 2d53d8598e7a6..b120ba1c0e607 100644 --- a/provisionersdk/proto/provisioner.proto +++ b/provisionersdk/proto/provisioner.proto @@ -419,6 +419,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 From 10fc9fe72fd0c2b3640f720e5b09f1584be67d72 Mon Sep 17 00:00:00 2001 From: Kacper Sawicki Date: Mon, 11 Aug 2025 12:01:11 +0000 Subject: [PATCH 02/14] generate api model --- site/src/api/typesGenerated.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 920409ae4ce05..6850e7a543992 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -939,6 +939,12 @@ export const Experiments: Experiment[] = [ "workspace-usage", ]; +// From codersdk/workspaces.go +export interface ExternalAgentCredentials { + readonly command: string; + readonly agent_token: string; +} + // From codersdk/externalauth.go export interface ExternalAuth { readonly authenticated: boolean; @@ -3876,6 +3882,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 5f344a1f4dde912d598f8e0ffe1a5992d1caacdd Mon Sep 17 00:00:00 2001 From: Kacper Sawicki Date: Mon, 11 Aug 2025 13:59:17 +0000 Subject: [PATCH 03/14] move /external-agent/{agent}/credentials endpoint to enterprise --- coderd/apidoc/docs.go | 2 +- coderd/apidoc/swagger.json | 2 +- coderd/coderd.go | 3 - coderd/workspaceagents.go | 68 ------------------- coderd/workspaceagents_test.go | 74 --------------------- codersdk/deployment.go | 5 +- docs/reference/api/agents.md | 39 ----------- docs/reference/api/enterprise.md | 39 +++++++++++ enterprise/coderd/coderd.go | 9 +++ enterprise/coderd/workspaceagents.go | 73 ++++++++++++++++++++ enterprise/coderd/workspaceagents_test.go | 81 +++++++++++++++++++++++ site/e2e/provisionerGenerated.ts | 4 ++ site/src/api/typesGenerated.ts | 2 + 13 files changed, 214 insertions(+), 187 deletions(-) diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 963c95d10efbc..9021a335a4bbe 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -10315,7 +10315,7 @@ const docTemplate = `{ "application/json" ], "tags": [ - "Agents" + "Enterprise" ], "summary": "Get workspace external agent credentials", "operationId": "get-workspace-external-agent-credentials", diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index c5d6297aada1c..d2a74d38be33f 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -9122,7 +9122,7 @@ } ], "produces": ["application/json"], - "tags": ["Agents"], + "tags": ["Enterprise"], "summary": "Get workspace external agent credentials", "operationId": "get-workspace-external-agent-credentials", "parameters": [ diff --git a/coderd/coderd.go b/coderd/coderd.go index 9ac574ff81358..a934536c0aef0 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -1430,9 +1430,6 @@ func New(options *Options) *API { r.Post("/", api.postWorkspaceAgentPortShare) r.Delete("/", api.deleteWorkspaceAgentPortShare) }) - r.Route("/external-agent", func(r chi.Router) { - r.Get("/{agent}/credentials", api.workspaceExternalAgentCredentials) - }) r.Get("/timings", api.workspaceTimings) r.Route("/acl", func(r chi.Router) { r.Use( diff --git a/coderd/workspaceagents.go b/coderd/workspaceagents.go index 945eca755270f..f2ee1ac18e823 100644 --- a/coderd/workspaceagents.go +++ b/coderd/workspaceagents.go @@ -2189,71 +2189,3 @@ func convertWorkspaceAgentLog(logEntry database.WorkspaceAgentLog) codersdk.Work SourceID: logEntry.LogSourceID, } } - -// @Summary Get workspace 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.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") - - 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 - } - - 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.StatusOK, codersdk.ExternalAgentCredentials{ - AgentToken: agent.AuthToken.String(), - Command: command, - }) -} diff --git a/coderd/workspaceagents_test.go b/coderd/workspaceagents_test.go index c95d2f15bf479..1855ed8a7e8fc 100644 --- a/coderd/workspaceagents_test.go +++ b/coderd/workspaceagents_test.go @@ -3136,77 +3136,3 @@ 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 - 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) - - 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 = "windows" - 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("$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) { - 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) - 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/deployment.go b/codersdk/deployment.go index 1d6fa4572772e..a70a6b55500d2 100644 --- a/codersdk/deployment.go +++ b/codersdk/deployment.go @@ -88,7 +88,8 @@ const ( // ManagedAgentLimit is a usage period feature, so the value in the license // contains both a soft and hard limit. Refer to // enterprise/coderd/license/license.go for the license format. - FeatureManagedAgentLimit FeatureName = "managed_agent_limit" + FeatureManagedAgentLimit FeatureName = "managed_agent_limit" + FeatureWorkspaceExternalAgent FeatureName = "workspace_external_agent" ) var ( @@ -115,6 +116,7 @@ var ( FeatureMultipleOrganizations, FeatureWorkspacePrebuilds, FeatureManagedAgentLimit, + FeatureWorkspaceExternalAgent, } // FeatureNamesMap is a map of all feature names for quick lookups. @@ -155,6 +157,7 @@ func (n FeatureName) AlwaysEnable() bool { FeatureCustomRoles: true, FeatureMultipleOrganizations: true, FeatureWorkspacePrebuilds: true, + FeatureWorkspaceExternalAgent: true, }[n] } diff --git a/docs/reference/api/agents.md b/docs/reference/api/agents.md index e72117e96e350..54e9b0e6ad628 100644 --- a/docs/reference/api/agents.md +++ b/docs/reference/api/agents.md @@ -1238,42 +1238,3 @@ Status Code **200** | `level` | `error` | To perform this operation, you must be authenticated. [Learn more](authentication.md). - -## Get workspace external agent credentials - -### Code samples - -```shell -# Example request using curl -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}/credentials` - -### 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", - "command": "string" -} -``` - -### Responses - -| 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/enterprise.md b/docs/reference/api/enterprise.md index 0ffae1116097d..b6043544d4766 100644 --- a/docs/reference/api/enterprise.md +++ b/docs/reference/api/enterprise.md @@ -4254,3 +4254,42 @@ curl -X PATCH http://coder-server:8080/api/v2/workspaceproxies/{workspaceproxy} | 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [codersdk.WorkspaceProxy](schemas.md#codersdkworkspaceproxy) | To perform this operation, you must be authenticated. [Learn more](authentication.md). + +## Get workspace external agent credentials + +### Code samples + +```shell +# Example request using curl +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}/credentials` + +### 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", + "command": "string" +} +``` + +### Responses + +| 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/enterprise/coderd/coderd.go b/enterprise/coderd/coderd.go index 40569ead70658..f039d10efbecb 100644 --- a/enterprise/coderd/coderd.go +++ b/enterprise/coderd/coderd.go @@ -506,6 +506,15 @@ func New(ctx context.Context, options *Options) (_ *API, err error) { apiKeyMiddleware, httpmw.ExtractNotificationTemplateParam(options.Database), ).Put("/notifications/templates/{notification_template}/method", api.updateNotificationTemplateMethod) + + r.Route("/workspaces/{workspace}/external-agent", func(r chi.Router) { + r.Use( + apiKeyMiddleware, + httpmw.ExtractWorkspaceParam(options.Database), + api.RequireFeatureMW(codersdk.FeatureWorkspaceExternalAgent), + ) + r.Get("/{agent}/credentials", api.workspaceExternalAgentCredentials) + }) }) if len(options.SCIMAPIKey) != 0 { diff --git a/enterprise/coderd/workspaceagents.go b/enterprise/coderd/workspaceagents.go index 3223151425630..22542d43e5b9c 100644 --- a/enterprise/coderd/workspaceagents.go +++ b/enterprise/coderd/workspaceagents.go @@ -2,9 +2,14 @@ package coderd import ( "context" + "fmt" "net/http" + "github.com/go-chi/chi/v5" + + "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/httpapi" + "github.com/coder/coder/v2/coderd/httpmw" "github.com/coder/coder/v2/codersdk" ) @@ -17,3 +22,71 @@ func (api *API) shouldBlockNonBrowserConnections(rw http.ResponseWriter) bool { } return false } + +// @Summary Get workspace external agent credentials +// @ID get-workspace-external-agent-credentials +// @Security CoderSessionToken +// @Produce json +// @Tags Enterprise +// @Param workspace path string true "Workspace ID" format(uuid) +// @Param agent path string true "Agent name" +// @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") + + 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 + } + + 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.StatusOK, codersdk.ExternalAgentCredentials{ + AgentToken: agent.AuthToken.String(), + Command: command, + }) +} diff --git a/enterprise/coderd/workspaceagents_test.go b/enterprise/coderd/workspaceagents_test.go index f4f0670cd150e..fcaa2468cc342 100644 --- a/enterprise/coderd/workspaceagents_test.go +++ b/enterprise/coderd/workspaceagents_test.go @@ -12,6 +12,7 @@ import ( "time" "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/dbfake" "github.com/coder/coder/v2/coderd/database/dbgen" "github.com/coder/coder/v2/coderd/database/dbtestutil" "github.com/coder/coder/v2/provisionersdk" @@ -344,3 +345,83 @@ func setupWorkspaceAgent(t *testing.T, client *codersdk.Client, user codersdk.Cr return setupResp{workspace, sdkAgent, agnt} } + +func TestWorkspaceExternalAgentCredentials(t *testing.T) { + t.Parallel() + + client, db, user := coderdenttest.NewWithDatabase(t, &coderdenttest.Options{ + LicenseOptions: &coderdenttest.LicenseOptions{ + Features: license.Features{ + codersdk.FeatureWorkspaceExternalAgent: 1, + }, + }, + }) + + 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) + + 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 = "windows" + 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("$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) { + 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) + 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/site/e2e/provisionerGenerated.ts b/site/e2e/provisionerGenerated.ts index 78a010f6c736f..00b2050d94d98 100644 --- a/site/e2e/provisionerGenerated.ts +++ b/site/e2e/provisionerGenerated.ts @@ -462,6 +462,7 @@ export interface PlanComplete { */ hasAiTasks: boolean; aiTasks: AITask[]; + hasExternalAgents: boolean; } /** @@ -1395,6 +1396,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; }, }; diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 6850e7a543992..2ae5b4339ba83 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -1058,6 +1058,7 @@ export type FeatureName = | "user_limit" | "user_role_management" | "workspace_batch_actions" + | "workspace_external_agent" | "workspace_prebuilds" | "workspace_proxy"; @@ -1081,6 +1082,7 @@ export const FeatureNames: FeatureName[] = [ "user_limit", "user_role_management", "workspace_batch_actions", + "workspace_external_agent", "workspace_prebuilds", "workspace_proxy", ]; From 28243e3c8e2962b8eb5d97d76e9ede9e7416a03f Mon Sep 17 00:00:00 2001 From: Kacper Sawicki Date: Mon, 11 Aug 2025 14:11:11 +0000 Subject: [PATCH 04/14] add Content-Digest header to init script response --- coderd/initscript.go | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/coderd/initscript.go b/coderd/initscript.go index 14769ce74179e..2051ca7f5f6e4 100644 --- a/coderd/initscript.go +++ b/coderd/initscript.go @@ -1,6 +1,8 @@ package coderd import ( + "crypto/sha256" + "encoding/base64" "fmt" "net/http" "strings" @@ -34,7 +36,10 @@ func (api *API) initScript(rw http.ResponseWriter, r *http.Request) { script = strings.ReplaceAll(script, "${ACCESS_URL}", api.AccessURL.String()+"/") script = strings.ReplaceAll(script, "${AUTH_TYPE}", "token") + scriptBytes := []byte(script) + hash := sha256.Sum256(scriptBytes) + rw.Header().Set("Content-Digest", fmt.Sprintf("sha256:%x", base64.StdEncoding.EncodeToString(hash[:]))) rw.Header().Set("Content-Type", "text/plain; charset=utf-8") rw.WriteHeader(http.StatusOK) - _, _ = rw.Write([]byte(script)) + _, _ = rw.Write(scriptBytes) } From d560e5cab5768c071a99454451fd0437d090c7f1 Mon Sep 17 00:00:00 2001 From: Kacper Sawicki Date: Tue, 12 Aug 2025 12:10:12 +0000 Subject: [PATCH 05/14] implement license checks for external agents in workspace builds and templates --- enterprise/coderd/coderd.go | 12 ++++++ enterprise/coderd/license/license.go | 63 +++++++++++++++++++++++++--- 2 files changed, 69 insertions(+), 6 deletions(-) diff --git a/enterprise/coderd/coderd.go b/enterprise/coderd/coderd.go index f039d10efbecb..8fde07401424c 100644 --- a/enterprise/coderd/coderd.go +++ b/enterprise/coderd/coderd.go @@ -952,6 +952,18 @@ func (api *API) CheckBuildUsage(ctx context.Context, store database.Store, templ // When there are no licenses installed, a noop usage checker is used // instead. + // If the template version has an external agent, we need to check that the + // license is entitled to this feature. + if templateVersion.HasExternalAgent.Valid && templateVersion.HasExternalAgent.Bool { + feature, ok := api.Entitlements.Feature(codersdk.FeatureWorkspaceExternalAgent) + if !ok || !feature.Enabled { + return wsbuilder.UsageCheckResponse{ + Permitted: false, + Message: "You have a template which uses external agents but your license is not entitled to this feature. You will be unable to create new workspaces from these templates.", + }, nil + } + } + // If the template version doesn't have an AI task, we don't need to check // usage. if !templateVersion.HasAITask.Valid || !templateVersion.HasAITask.Bool { diff --git a/enterprise/coderd/license/license.go b/enterprise/coderd/license/license.go index 687a4aaf66746..504c9a04caea0 100644 --- a/enterprise/coderd/license/license.go +++ b/enterprise/coderd/license/license.go @@ -3,6 +3,7 @@ package license import ( "context" "crypto/ed25519" + "database/sql" "fmt" "math" "time" @@ -94,10 +95,34 @@ func Entitlements( return codersdk.Entitlements{}, xerrors.Errorf("query active user count: %w", err) } + // nolint:gocritic // Getting external workspaces is a system function. + externalWorkspaces, err := db.GetWorkspaces(dbauthz.AsSystemRestricted(ctx), database.GetWorkspacesParams{ + HasExternalAgent: sql.NullBool{ + Bool: true, + Valid: true, + }, + }) + if err != nil { + return codersdk.Entitlements{}, xerrors.Errorf("query external workspaces: %w", err) + } + + // nolint:gocritic // Getting external templates is a system function. + externalTemplates, err := db.GetTemplatesWithFilter(dbauthz.AsSystemRestricted(ctx), database.GetTemplatesWithFilterParams{ + HasExternalAgent: sql.NullBool{ + Bool: true, + Valid: true, + }, + }) + if err != nil { + return codersdk.Entitlements{}, xerrors.Errorf("query external templates: %w", err) + } + entitlements, err := LicensesEntitlements(ctx, now, licenses, enablements, keys, FeatureArguments{ - ActiveUserCount: activeUserCount, - ReplicaCount: replicaCount, - ExternalAuthCount: externalAuthCount, + ActiveUserCount: activeUserCount, + ReplicaCount: replicaCount, + ExternalAuthCount: externalAuthCount, + ExternalWorkspaceCount: int64(len(externalWorkspaces)), + ExternalTemplateCount: int64(len(externalTemplates)), ManagedAgentCountFn: func(ctx context.Context, startTime time.Time, endTime time.Time) (int64, error) { // nolint:gocritic // Requires permission to read all workspaces to read managed agent count. return db.GetManagedAgentCount(dbauthz.AsSystemRestricted(ctx), database.GetManagedAgentCountParams{ @@ -114,9 +139,11 @@ func Entitlements( } type FeatureArguments struct { - ActiveUserCount int64 - ReplicaCount int - ExternalAuthCount int + ActiveUserCount int64 + ReplicaCount int + ExternalAuthCount int + ExternalWorkspaceCount int64 + ExternalTemplateCount int64 // Unfortunately, managed agent count is not a simple count of the current // state of the world, but a count between two points in time determined by // the licenses. @@ -418,6 +445,30 @@ func LicensesEntitlements( } } + if featureArguments.ExternalWorkspaceCount > 0 { + feature := entitlements.Features[codersdk.FeatureWorkspaceExternalAgent] + switch feature.Entitlement { + case codersdk.EntitlementNotEntitled: + entitlements.Errors = append(entitlements.Errors, + "You have external workspaces but your license is not entitled to this feature.") + case codersdk.EntitlementGracePeriod: + entitlements.Warnings = append(entitlements.Warnings, + "You have external workspaces but your license is expired.") + } + } + + if featureArguments.ExternalTemplateCount > 0 { + feature := entitlements.Features[codersdk.FeatureWorkspaceExternalAgent] + switch feature.Entitlement { + case codersdk.EntitlementNotEntitled: + entitlements.Errors = append(entitlements.Errors, + "You have templates which use external agents but your license is not entitled to this feature.") + case codersdk.EntitlementGracePeriod: + entitlements.Warnings = append(entitlements.Warnings, + "You have templates which use external agents but your license is expired.") + } + } + // Managed agent warnings are applied based on usage period. We only // generate a warning if the license actually has managed agents. // Note that agents are free when unlicensed. From 732bc1dd0f1362d77500a9a904ccb4069a990a89 Mon Sep 17 00:00:00 2001 From: Kacper Sawicki Date: Tue, 12 Aug 2025 13:12:03 +0000 Subject: [PATCH 06/14] Update license_test.go --- enterprise/coderd/license/license_test.go | 33 +++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/enterprise/coderd/license/license_test.go b/enterprise/coderd/license/license_test.go index d8203117039cb..0ca7d2287ad63 100644 --- a/enterprise/coderd/license/license_test.go +++ b/enterprise/coderd/license/license_test.go @@ -723,6 +723,12 @@ func TestEntitlements(t *testing.T) { return true })). Return(int64(175), nil) + mDB.EXPECT(). + GetWorkspaces(gomock.Any(), gomock.Any()). + Return([]database.GetWorkspacesRow{}, nil) + mDB.EXPECT(). + GetTemplatesWithFilter(gomock.Any(), gomock.Any()). + Return([]database.Template{}, nil) entitlements, err := license.Entitlements(context.Background(), mDB, 1, 0, coderdenttest.Keys, all) require.NoError(t, err) @@ -766,6 +772,7 @@ func TestLicenseEntitlements(t *testing.T) { codersdk.FeatureUserRoleManagement: true, codersdk.FeatureAccessControl: true, codersdk.FeatureControlSharedPorts: true, + codersdk.FeatureWorkspaceExternalAgent: true, } legacyLicense := func() *coderdenttest.LicenseOptions { @@ -1109,6 +1116,32 @@ func TestLicenseEntitlements(t *testing.T) { assert.Equal(t, int64(200), *feature.Actual) }, }, + { + Name: "ExternalWorkspace", + Licenses: []*coderdenttest.LicenseOptions{ + enterpriseLicense().UserLimit(100), + }, + Arguments: license.FeatureArguments{ + ExternalWorkspaceCount: 1, + }, + AssertEntitlements: func(t *testing.T, entitlements codersdk.Entitlements) { + assert.Equal(t, codersdk.EntitlementEntitled, entitlements.Features[codersdk.FeatureWorkspaceExternalAgent].Entitlement) + assert.True(t, entitlements.Features[codersdk.FeatureWorkspaceExternalAgent].Enabled) + }, + }, + { + Name: "ExternalTemplate", + Licenses: []*coderdenttest.LicenseOptions{ + enterpriseLicense().UserLimit(100), + }, + Arguments: license.FeatureArguments{ + ExternalTemplateCount: 1, + }, + AssertEntitlements: func(t *testing.T, entitlements codersdk.Entitlements) { + assert.Equal(t, codersdk.EntitlementEntitled, entitlements.Features[codersdk.FeatureWorkspaceExternalAgent].Entitlement) + assert.True(t, entitlements.Features[codersdk.FeatureWorkspaceExternalAgent].Enabled) + }, + }, } for _, tc := range testCases { From 5ca172ecf3984808fc920cec2b42f98925add416 Mon Sep 17 00:00:00 2001 From: Kacper Sawicki Date: Wed, 13 Aug 2025 07:13:29 +0000 Subject: [PATCH 07/14] Apply review suggestions --- coderd/initscript_test.go | 26 +++++++++++++++++++++-- coderd/searchquery/search.go | 4 ++-- coderd/searchquery/search_test.go | 8 +++---- coderd/templates_test.go | 4 ++-- coderd/workspaces.go | 2 +- enterprise/coderd/workspaceagents.go | 6 ++++++ enterprise/coderd/workspaceagents_test.go | 16 ++++++++++++++ 7 files changed, 55 insertions(+), 11 deletions(-) diff --git a/coderd/initscript_test.go b/coderd/initscript_test.go index c47162ff3b6e9..bad0577f0218f 100644 --- a/coderd/initscript_test.go +++ b/coderd/initscript_test.go @@ -14,22 +14,44 @@ import ( func TestInitScript(t *testing.T) { t.Parallel() - t.Run("OK Windows", func(t *testing.T) { + t.Run("OK Windows amd64", 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\"") + require.Contains(t, script, "/bin/coder-windows-amd64.exe") }) - t.Run("OK Linux", func(t *testing.T) { + t.Run("OK Windows arm64", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, nil) + script, err := client.InitScript(context.Background(), "windows", "arm64") + require.NoError(t, err) + require.NotEmpty(t, script) + require.Contains(t, script, "$env:CODER_AGENT_AUTH = \"token\"") + require.Contains(t, script, "/bin/coder-windows-arm64.exe") + }) + + t.Run("OK Linux amd64", 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\"") + require.Contains(t, script, "/bin/coder-linux-amd64") + }) + + t.Run("OK Linux arm64", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, nil) + script, err := client.InitScript(context.Background(), "linux", "arm64") + require.NoError(t, err) + require.NotEmpty(t, script) + require.Contains(t, script, "export CODER_AGENT_AUTH=\"token\"") + require.Contains(t, script, "/bin/coder-linux-arm64") }) t.Run("BadRequest", func(t *testing.T) { diff --git a/coderd/searchquery/search.go b/coderd/searchquery/search.go index e70d402d17c1e..974872973606c 100644 --- a/coderd/searchquery/search.go +++ b/coderd/searchquery/search.go @@ -223,7 +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.HasExternalAgent = parser.NullableBoolean(values, sql.NullBool{}, "has_external_agent") filter.OrganizationID = parseOrganization(ctx, db, parser, values, "organization") type paramMatch struct { @@ -287,7 +287,7 @@ func Templates(ctx context.Context, db database.Store, actorID uuid.UUID, query 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"), + HasExternalAgent: parser.NullableBoolean(values, sql.NullBool{}, "has_external_agent"), } if filter.AuthorUsername == codersdk.Me { diff --git a/coderd/searchquery/search_test.go b/coderd/searchquery/search_test.go index 36fc06e2d1479..2a8f4cd6cbb56 100644 --- a/coderd/searchquery/search_test.go +++ b/coderd/searchquery/search_test.go @@ -254,7 +254,7 @@ func TestSearchWorkspace(t *testing.T) { }, { Name: "HasExternalAgentTrue", - Query: "has-external-agent:true", + Query: "has_external_agent:true", Expected: database.GetWorkspacesParams{ HasExternalAgent: sql.NullBool{ Bool: true, @@ -264,7 +264,7 @@ func TestSearchWorkspace(t *testing.T) { }, { Name: "HasExternalAgentFalse", - Query: "has-external-agent:false", + Query: "has_external_agent:false", Expected: database.GetWorkspacesParams{ HasExternalAgent: sql.NullBool{ Bool: false, @@ -721,7 +721,7 @@ func TestSearchTemplates(t *testing.T) { }, { Name: "HasExternalAgent", - Query: "has-external-agent:true", + Query: "has_external_agent:true", Expected: database.GetTemplatesWithFilterParams{ HasExternalAgent: sql.NullBool{ Bool: true, @@ -731,7 +731,7 @@ func TestSearchTemplates(t *testing.T) { }, { Name: "HasExternalAgentFalse", - Query: "has-external-agent:false", + Query: "has_external_agent:false", Expected: database.GetTemplatesWithFilterParams{ HasExternalAgent: sql.NullBool{ Bool: false, diff --git a/coderd/templates_test.go b/coderd/templates_test.go index f02d861ac9928..c470dd17c664a 100644 --- a/coderd/templates_test.go +++ b/coderd/templates_test.go @@ -2058,14 +2058,14 @@ func TestTemplateFilterHasExternalAgent(t *testing.T) { defer cancel() templates, err := client.Templates(ctx, codersdk.TemplateFilter{ - SearchQuery: "has-external-agent:true", + 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", + SearchQuery: "has_external_agent:false", }) require.NoError(t, err) require.Len(t, templates, 1) diff --git a/coderd/workspaces.go b/coderd/workspaces.go index 85cd3086c71df..23bd8c5f6ed9e 100644 --- a/coderd/workspaces.go +++ b/coderd/workspaces.go @@ -138,7 +138,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, has-external-agent." +// @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/enterprise/coderd/workspaceagents.go b/enterprise/coderd/workspaceagents.go index 22542d43e5b9c..0e32d550232f5 100644 --- a/enterprise/coderd/workspaceagents.go +++ b/enterprise/coderd/workspaceagents.go @@ -45,6 +45,12 @@ func (api *API) workspaceExternalAgentCredentials(rw http.ResponseWriter, r *htt }) return } + if !build.HasExternalAgent.Bool { + httpapi.Write(ctx, rw, http.StatusNotFound, codersdk.Response{ + Message: "Workspace does not have an external agent.", + }) + return + } agents, err := api.Database.GetWorkspaceAgentsByWorkspaceAndBuildNumber(ctx, database.GetWorkspaceAgentsByWorkspaceAndBuildNumberParams{ WorkspaceID: workspace.ID, diff --git a/enterprise/coderd/workspaceagents_test.go b/enterprise/coderd/workspaceagents_test.go index fcaa2468cc342..dd967354bce16 100644 --- a/enterprise/coderd/workspaceagents_test.go +++ b/enterprise/coderd/workspaceagents_test.go @@ -424,4 +424,20 @@ func TestWorkspaceExternalAgentCredentials(t *testing.T) { require.ErrorAs(t, err, &apiErr) require.Equal(t, "External agent is authenticated with an instance ID.", apiErr.Message) }) + + t.Run("No external agent - 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, + }).Do() + + _, err := client.WorkspaceExternalAgentCredentials(ctx, r.Workspace.ID, "test-agent") + require.Error(t, err) + var apiErr *codersdk.Error + require.ErrorAs(t, err, &apiErr) + require.Equal(t, "Workspace does not have an external agent.", apiErr.Message) + }) } From 10e6853fca643b380793e743c05e92d5fff96ac7 Mon Sep 17 00:00:00 2001 From: Kacper Sawicki Date: Wed, 13 Aug 2025 07:41:46 +0000 Subject: [PATCH 08/14] Add HasExternalAgent to TemplateVersion response --- coderd/apidoc/docs.go | 5 ++++- coderd/apidoc/swagger.json | 5 ++++- coderd/templateversions.go | 1 + coderd/templateversions_test.go | 33 ++++++++++++++++++++++++++++++++ codersdk/templateversions.go | 2 ++ docs/reference/api/schemas.md | 2 ++ docs/reference/api/templates.md | 9 +++++++++ docs/reference/api/workspaces.md | 2 +- site/src/api/typesGenerated.ts | 1 + site/src/testHelpers/entities.ts | 2 ++ 10 files changed, 59 insertions(+), 3 deletions(-) diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 9021a335a4bbe..f0177a23924e4 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -9868,7 +9868,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, has-external-agent.", + "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" }, @@ -16902,6 +16902,9 @@ const docTemplate = `{ "created_by": { "$ref": "#/definitions/codersdk.MinimalUser" }, + "has_external_agent": { + "type": "boolean" + }, "id": { "type": "string", "format": "uuid" diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index d2a74d38be33f..87d7d48def404 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -8722,7 +8722,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, has-external-agent.", + "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" }, @@ -15415,6 +15415,9 @@ "created_by": { "$ref": "#/definitions/codersdk.MinimalUser" }, + "has_external_agent": { + "type": "boolean" + }, "id": { "type": "string", "format": "uuid" diff --git a/coderd/templateversions.go b/coderd/templateversions.go index 2c02268bba0a9..17a4d9b451e9c 100644 --- a/coderd/templateversions.go +++ b/coderd/templateversions.go @@ -1963,6 +1963,7 @@ func convertTemplateVersion(version database.TemplateVersion, job codersdk.Provi Archived: version.Archived, Warnings: warnings, MatchedProvisioners: matchedProvisioners, + HasExternalAgent: version.HasExternalAgent.Bool, } } diff --git a/coderd/templateversions_test.go b/coderd/templateversions_test.go index 0b5bf6fcf2302..48f690d26d2eb 100644 --- a/coderd/templateversions_test.go +++ b/coderd/templateversions_test.go @@ -2221,3 +2221,36 @@ func TestTemplateArchiveVersions(t *testing.T) { require.NoError(t, err, "fetch all versions") require.Len(t, remaining, totalVersions-len(expArchived)-len(allFailed)+1, "remaining versions") } + +func TestTemplateVersionHasExternalAgent(t *testing.T) { + t.Parallel() + + client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) + user := coderdtest.CreateFirstUser(t, client) + + ctx := testutil.Context(t, testutil.WaitMedium) + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ + Parse: echo.ParseComplete, + ProvisionPlan: []*proto.Response{ + { + Type: &proto.Response_Plan{ + Plan: &proto.PlanComplete{ + Resources: []*proto.Resource{ + { + Name: "example", + Type: "coder_external_agent", + }, + }, + HasExternalAgents: true, + }, + }, + }, + }, + ProvisionApply: echo.ApplyComplete, + }) + coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) + + version, err := client.TemplateVersion(ctx, version.ID) + require.NoError(t, err) + require.True(t, version.HasExternalAgent) +} diff --git a/codersdk/templateversions.go b/codersdk/templateversions.go index a47cbb685898b..992797578630d 100644 --- a/codersdk/templateversions.go +++ b/codersdk/templateversions.go @@ -33,6 +33,8 @@ type TemplateVersion struct { Warnings []TemplateVersionWarning `json:"warnings,omitempty" enums:"DEPRECATED_PARAMETERS"` MatchedProvisioners *MatchedProvisioners `json:"matched_provisioners,omitempty"` + + HasExternalAgent bool `json:"has_external_agent"` } type TemplateVersionExternalAuth struct { diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index de5b6c12ae7ae..a886ae0dbc795 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -7630,6 +7630,7 @@ Restarts will only happen on weekdays in this list on weeks which line up with W "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "username": "string" }, + "has_external_agent": true, "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "job": { "available_workers": [ @@ -7694,6 +7695,7 @@ Restarts will only happen on weekdays in this list on weeks which line up with W | `archived` | boolean | false | | | | `created_at` | string | false | | | | `created_by` | [codersdk.MinimalUser](#codersdkminimaluser) | false | | | +| `has_external_agent` | boolean | false | | | | `id` | string | false | | | | `job` | [codersdk.ProvisionerJob](#codersdkprovisionerjob) | false | | | | `matched_provisioners` | [codersdk.MatchedProvisioners](#codersdkmatchedprovisioners) | false | | | diff --git a/docs/reference/api/templates.md b/docs/reference/api/templates.md index ea2e2c50cca7f..f3df204750ca6 100644 --- a/docs/reference/api/templates.md +++ b/docs/reference/api/templates.md @@ -462,6 +462,7 @@ curl -X GET http://coder-server:8080/api/v2/organizations/{organization}/templat "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "username": "string" }, + "has_external_agent": true, "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "job": { "available_workers": [ @@ -561,6 +562,7 @@ curl -X GET http://coder-server:8080/api/v2/organizations/{organization}/templat "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "username": "string" }, + "has_external_agent": true, "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "job": { "available_workers": [ @@ -684,6 +686,7 @@ curl -X POST http://coder-server:8080/api/v2/organizations/{organization}/templa "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "username": "string" }, + "has_external_agent": true, "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "job": { "available_workers": [ @@ -1250,6 +1253,7 @@ curl -X GET http://coder-server:8080/api/v2/templates/{template}/versions \ "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "username": "string" }, + "has_external_agent": true, "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "job": { "available_workers": [ @@ -1327,6 +1331,7 @@ Status Code **200** | `»» avatar_url` | string(uri) | false | | | | `»» id` | string(uuid) | true | | | | `»» username` | string | true | | | +| `» has_external_agent` | boolean | false | | | | `» id` | string(uuid) | false | | | | `» job` | [codersdk.ProvisionerJob](schemas.md#codersdkprovisionerjob) | false | | | | `»» available_workers` | array | false | | | @@ -1531,6 +1536,7 @@ curl -X GET http://coder-server:8080/api/v2/templates/{template}/versions/{templ "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "username": "string" }, + "has_external_agent": true, "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "job": { "available_workers": [ @@ -1608,6 +1614,7 @@ Status Code **200** | `»» avatar_url` | string(uri) | false | | | | `»» id` | string(uuid) | true | | | | `»» username` | string | true | | | +| `» has_external_agent` | boolean | false | | | | `» id` | string(uuid) | false | | | | `» job` | [codersdk.ProvisionerJob](schemas.md#codersdkprovisionerjob) | false | | | | `»» available_workers` | array | false | | | @@ -1702,6 +1709,7 @@ curl -X GET http://coder-server:8080/api/v2/templateversions/{templateversion} \ "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "username": "string" }, + "has_external_agent": true, "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "job": { "available_workers": [ @@ -1810,6 +1818,7 @@ curl -X PATCH http://coder-server:8080/api/v2/templateversions/{templateversion} "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "username": "string" }, + "has_external_agent": true, "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "job": { "available_workers": [ diff --git a/docs/reference/api/workspaces.md b/docs/reference/api/workspaces.md index 4f201bd139d21..ffa18b46c8df9 100644 --- a/docs/reference/api/workspaces.md +++ b/docs/reference/api/workspaces.md @@ -935,7 +935,7 @@ curl -X GET http://coder-server:8080/api/v2/workspaces \ | 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. | +| `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 | diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 2ae5b4339ba83..4f873fb7b7829 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -3008,6 +3008,7 @@ export interface TemplateVersion { readonly archived: boolean; readonly warnings?: readonly TemplateVersionWarning[]; readonly matched_provisioners?: MatchedProvisioners; + readonly has_external_agent: boolean; } // From codersdk/templateversions.go diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index e0f692dacbfec..c130c952185fd 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -732,6 +732,7 @@ You can add instructions here [Some link info](https://coder.com)`, created_by: MockUserOwner, archived: false, + has_external_agent: false, }; export const MockTemplateVersion2: TypesGen.TemplateVersion = { @@ -751,6 +752,7 @@ You can add instructions here [Some link info](https://coder.com)`, created_by: MockUserOwner, archived: false, + has_external_agent: false, }; export const MockTemplateVersionWithMarkdownMessage: TypesGen.TemplateVersion = From 760de9a6a3ce68033e1c0e8cc65adae7c2b42998 Mon Sep 17 00:00:00 2001 From: Kacper Sawicki Date: Wed, 13 Aug 2025 10:05:57 +0000 Subject: [PATCH 09/14] Update tests --- enterprise/coderd/workspaceagents_test.go | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/enterprise/coderd/workspaceagents_test.go b/enterprise/coderd/workspaceagents_test.go index dd967354bce16..733241cc6bc42 100644 --- a/enterprise/coderd/workspaceagents_test.go +++ b/enterprise/coderd/workspaceagents_test.go @@ -3,6 +3,7 @@ package coderd_test import ( "context" "crypto/tls" + "database/sql" "fmt" "net/http" "os" @@ -364,6 +365,11 @@ func TestWorkspaceExternalAgentCredentials(t *testing.T) { r := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{ OrganizationID: user.OrganizationID, OwnerID: user.UserID, + }).Seed(database.WorkspaceBuild{ + HasExternalAgent: sql.NullBool{ + Bool: true, + Valid: true, + }, }).WithAgent(func(a []*proto.Agent) []*proto.Agent { a[0].Name = "test-agent" a[0].OperatingSystem = "linux" @@ -387,6 +393,11 @@ func TestWorkspaceExternalAgentCredentials(t *testing.T) { r := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{ OrganizationID: user.OrganizationID, OwnerID: user.UserID, + }).Seed(database.WorkspaceBuild{ + HasExternalAgent: sql.NullBool{ + Bool: true, + Valid: true, + }, }).WithAgent(func(a []*proto.Agent) []*proto.Agent { a[0].Name = "test-agent" a[0].OperatingSystem = "windows" @@ -410,6 +421,11 @@ func TestWorkspaceExternalAgentCredentials(t *testing.T) { r := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{ OrganizationID: user.OrganizationID, OwnerID: user.UserID, + }).Seed(database.WorkspaceBuild{ + HasExternalAgent: sql.NullBool{ + Bool: true, + Valid: true, + }, }).WithAgent(func(a []*proto.Agent) []*proto.Agent { a[0].Name = "test-agent" a[0].Auth = &proto.Agent_InstanceId{ From 3e2288bd04012226507a4489c71443c32487996f Mon Sep 17 00:00:00 2001 From: Kacper Sawicki Date: Wed, 13 Aug 2025 10:42:21 +0000 Subject: [PATCH 10/14] add HasLicense field to Options and update UsageChecker --- coderd/coderd.go | 2 ++ enterprise/coderd/coderd.go | 69 +++++++++++++++++-------------------- 2 files changed, 33 insertions(+), 38 deletions(-) diff --git a/coderd/coderd.go b/coderd/coderd.go index a934536c0aef0..d982f509e8158 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -180,6 +180,8 @@ type Options struct { // Entitlements can come from the enterprise caller if enterprise code is // included. Entitlements *entitlements.Set + // HasLicense indicates if a license is installed. + HasLicense bool // PostAuthAdditionalHeadersFunc is used to add additional headers to the response // after a successful authentication. // This is somewhat janky, but seemingly the only reasonable way to add a header diff --git a/enterprise/coderd/coderd.go b/enterprise/coderd/coderd.go index 8fde07401424c..5b8c97409c481 100644 --- a/enterprise/coderd/coderd.go +++ b/enterprise/coderd/coderd.go @@ -739,6 +739,8 @@ func (api *API) updateEntitlements(ctx context.Context) error { return codersdk.Entitlements{}, err } + api.HasLicense = reloadedEntitlements.HasLicense + if reloadedEntitlements.RequireTelemetry && !api.DeploymentValues.Telemetry.Enable.Value() { api.Logger.Error(ctx, "license requires telemetry enabled") return codersdk.Entitlements{}, entitlements.ErrLicenseRequiresTelemetry @@ -929,17 +931,9 @@ func (api *API) updateEntitlements(ctx context.Context) error { } reloadedEntitlements.Features[codersdk.FeatureExternalTokenEncryption] = featureExternalTokenEncryption - // If there's a license installed, we will use the enterprise build - // limit checker. - // This checker currently only enforces the managed agent limit. - if reloadedEntitlements.HasLicense { - var checker wsbuilder.UsageChecker = api - api.AGPL.BuildUsageChecker.Store(&checker) - } else { - // Don't check any usage, just like AGPL. - var checker wsbuilder.UsageChecker = wsbuilder.NoopUsageChecker{} - api.AGPL.BuildUsageChecker.Store(&checker) - } + // Always use the enterprise usage checker + var checker wsbuilder.UsageChecker = api + api.AGPL.BuildUsageChecker.Store(&checker) return reloadedEntitlements, nil }) @@ -948,10 +942,6 @@ func (api *API) updateEntitlements(ctx context.Context) error { var _ wsbuilder.UsageChecker = &API{} func (api *API) CheckBuildUsage(ctx context.Context, store database.Store, templateVersion *database.TemplateVersion) (wsbuilder.UsageCheckResponse, error) { - // We assume that if this function is called, a valid license is installed. - // When there are no licenses installed, a noop usage checker is used - // instead. - // If the template version has an external agent, we need to check that the // license is entitled to this feature. if templateVersion.HasExternalAgent.Valid && templateVersion.HasExternalAgent.Bool { @@ -972,32 +962,35 @@ func (api *API) CheckBuildUsage(ctx context.Context, store database.Store, templ }, nil } - // Otherwise, we need to check that we haven't breached the managed agent + // When unlicensed, we need to check that we haven't breached the managed agent // limit. - managedAgentLimit, ok := api.Entitlements.Feature(codersdk.FeatureManagedAgentLimit) - if !ok || !managedAgentLimit.Enabled || managedAgentLimit.Limit == nil || managedAgentLimit.UsagePeriod == nil { - return wsbuilder.UsageCheckResponse{ - Permitted: false, - Message: "Your license is not entitled to managed agents. Please contact sales to continue using managed agents.", - }, nil - } + // Unlicensed deployments are allowed to use unlimited managed agents. + if api.HasLicense { + managedAgentLimit, ok := api.Entitlements.Feature(codersdk.FeatureManagedAgentLimit) + if !ok || !managedAgentLimit.Enabled || managedAgentLimit.Limit == nil || managedAgentLimit.UsagePeriod == nil { + return wsbuilder.UsageCheckResponse{ + Permitted: false, + Message: "Your license is not entitled to managed agents. Please contact sales to continue using managed agents.", + }, nil + } - // This check is intentionally not committed to the database. It's fine if - // it's not 100% accurate or allows for minor breaches due to build races. - // nolint:gocritic // Requires permission to read all workspaces to read managed agent count. - managedAgentCount, err := store.GetManagedAgentCount(agpldbauthz.AsSystemRestricted(ctx), database.GetManagedAgentCountParams{ - StartTime: managedAgentLimit.UsagePeriod.Start, - EndTime: managedAgentLimit.UsagePeriod.End, - }) - if err != nil { - return wsbuilder.UsageCheckResponse{}, xerrors.Errorf("get managed agent count: %w", err) - } + // This check is intentionally not committed to the database. It's fine if + // it's not 100% accurate or allows for minor breaches due to build races. + // nolint:gocritic // Requires permission to read all workspaces to read managed agent count. + managedAgentCount, err := store.GetManagedAgentCount(agpldbauthz.AsSystemRestricted(ctx), database.GetManagedAgentCountParams{ + StartTime: managedAgentLimit.UsagePeriod.Start, + EndTime: managedAgentLimit.UsagePeriod.End, + }) + if err != nil { + return wsbuilder.UsageCheckResponse{}, xerrors.Errorf("get managed agent count: %w", err) + } - if managedAgentCount >= *managedAgentLimit.Limit { - return wsbuilder.UsageCheckResponse{ - Permitted: false, - Message: "You have breached the managed agent limit in your license. Please contact sales to continue using managed agents.", - }, nil + if managedAgentCount >= *managedAgentLimit.Limit { + return wsbuilder.UsageCheckResponse{ + Permitted: false, + Message: "You have breached the managed agent limit in your license. Please contact sales to continue using managed agents.", + }, nil + } } return wsbuilder.UsageCheckResponse{ From ee58237e909c3914f60b38d82c798d3f0adf2188 Mon Sep 17 00:00:00 2001 From: Kacper Sawicki Date: Wed, 13 Aug 2025 11:33:05 +0000 Subject: [PATCH 11/14] remove HasLicense field from Options and implement HasLicense method in Entitlements --- coderd/coderd.go | 2 -- coderd/entitlements/entitlements.go | 6 ++++++ enterprise/coderd/coderd.go | 4 +--- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/coderd/coderd.go b/coderd/coderd.go index d982f509e8158..a934536c0aef0 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -180,8 +180,6 @@ type Options struct { // Entitlements can come from the enterprise caller if enterprise code is // included. Entitlements *entitlements.Set - // HasLicense indicates if a license is installed. - HasLicense bool // PostAuthAdditionalHeadersFunc is used to add additional headers to the response // after a successful authentication. // This is somewhat janky, but seemingly the only reasonable way to add a header diff --git a/coderd/entitlements/entitlements.go b/coderd/entitlements/entitlements.go index 6bbe32ade4a1b..1be422b4765ee 100644 --- a/coderd/entitlements/entitlements.go +++ b/coderd/entitlements/entitlements.go @@ -161,3 +161,9 @@ func (l *Set) Errors() []string { defer l.entitlementsMu.RUnlock() return slices.Clone(l.entitlements.Errors) } + +func (l *Set) HasLicense() bool { + l.entitlementsMu.RLock() + defer l.entitlementsMu.RUnlock() + return l.entitlements.HasLicense +} diff --git a/enterprise/coderd/coderd.go b/enterprise/coderd/coderd.go index 5b8c97409c481..8190de103cd7a 100644 --- a/enterprise/coderd/coderd.go +++ b/enterprise/coderd/coderd.go @@ -739,8 +739,6 @@ func (api *API) updateEntitlements(ctx context.Context) error { return codersdk.Entitlements{}, err } - api.HasLicense = reloadedEntitlements.HasLicense - if reloadedEntitlements.RequireTelemetry && !api.DeploymentValues.Telemetry.Enable.Value() { api.Logger.Error(ctx, "license requires telemetry enabled") return codersdk.Entitlements{}, entitlements.ErrLicenseRequiresTelemetry @@ -965,7 +963,7 @@ func (api *API) CheckBuildUsage(ctx context.Context, store database.Store, templ // When unlicensed, we need to check that we haven't breached the managed agent // limit. // Unlicensed deployments are allowed to use unlimited managed agents. - if api.HasLicense { + if api.Entitlements.HasLicense() { managedAgentLimit, ok := api.Entitlements.Feature(codersdk.FeatureManagedAgentLimit) if !ok || !managedAgentLimit.Enabled || managedAgentLimit.Limit == nil || managedAgentLimit.UsagePeriod == nil { return wsbuilder.UsageCheckResponse{ From b91397db2affbfc26d14c3a73c93191b90ed1994 Mon Sep 17 00:00:00 2001 From: Kacper Sawicki Date: Wed, 13 Aug 2025 11:56:48 +0000 Subject: [PATCH 12/14] return 404 if external_agent is not associated with agent --- enterprise/coderd/workspaceagents.go | 23 +++++++++++++ enterprise/coderd/workspaceagents_test.go | 39 +++++++++++++++++++++++ 2 files changed, 62 insertions(+) diff --git a/enterprise/coderd/workspaceagents.go b/enterprise/coderd/workspaceagents.go index 0e32d550232f5..2a1d448f74df6 100644 --- a/enterprise/coderd/workspaceagents.go +++ b/enterprise/coderd/workspaceagents.go @@ -52,6 +52,29 @@ func (api *API) workspaceExternalAgentCredentials(rw http.ResponseWriter, r *htt return } + resources, err := api.Database.GetWorkspaceResourcesByJobID(ctx, build.JobID) + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Failed to get workspace resources.", + Detail: err.Error(), + }) + return + } + + found := false + for _, resource := range resources { + if resource.Type == "coder_external_agent" && resource.Name == agentName { + found = true + break + } + } + if !found { + httpapi.Write(ctx, rw, http.StatusNotFound, codersdk.Response{ + Message: fmt.Sprintf("Agent '%s' does not have an external agent associated with it.", agentName), + }) + return + } + agents, err := api.Database.GetWorkspaceAgentsByWorkspaceAndBuildNumber(ctx, database.GetWorkspaceAgentsByWorkspaceAndBuildNumberParams{ WorkspaceID: workspace.ID, BuildNumber: build.BuildNumber, diff --git a/enterprise/coderd/workspaceagents_test.go b/enterprise/coderd/workspaceagents_test.go index 733241cc6bc42..a4f676585d413 100644 --- a/enterprise/coderd/workspaceagents_test.go +++ b/enterprise/coderd/workspaceagents_test.go @@ -370,6 +370,9 @@ func TestWorkspaceExternalAgentCredentials(t *testing.T) { Bool: true, Valid: true, }, + }).Resource(&proto.Resource{ + Name: "test-agent", + Type: "coder_external_agent", }).WithAgent(func(a []*proto.Agent) []*proto.Agent { a[0].Name = "test-agent" a[0].OperatingSystem = "linux" @@ -393,6 +396,9 @@ func TestWorkspaceExternalAgentCredentials(t *testing.T) { r := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{ OrganizationID: user.OrganizationID, OwnerID: user.UserID, + }).Resource(&proto.Resource{ + Name: "test-agent", + Type: "coder_external_agent", }).Seed(database.WorkspaceBuild{ HasExternalAgent: sql.NullBool{ Bool: true, @@ -426,6 +432,9 @@ func TestWorkspaceExternalAgentCredentials(t *testing.T) { Bool: true, Valid: true, }, + }).Resource(&proto.Resource{ + Name: "test-agent", + Type: "coder_external_agent", }).WithAgent(func(a []*proto.Agent) []*proto.Agent { a[0].Name = "test-agent" a[0].Auth = &proto.Agent_InstanceId{ @@ -456,4 +465,34 @@ func TestWorkspaceExternalAgentCredentials(t *testing.T) { require.ErrorAs(t, err, &apiErr) require.Equal(t, "Workspace does not have an external agent.", apiErr.Message) }) + + t.Run("No external agent associated with agent - 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, + }).Seed(database.WorkspaceBuild{ + HasExternalAgent: sql.NullBool{ + Bool: true, + Valid: true, + }, + }).Resource(&proto.Resource{ + Name: "test-external-agent", + Type: "coder_external_agent", + }).WithAgent(func(a []*proto.Agent) []*proto.Agent { + a[0].Name = "test-agent-no-external-agent" + a[0].Auth = &proto.Agent_Token{ + Token: uuid.NewString(), + } + return a + }).Do() + + _, err := client.WorkspaceExternalAgentCredentials(ctx, r.Workspace.ID, "test-agent-no-external-agent") + require.Error(t, err) + var apiErr *codersdk.Error + require.ErrorAs(t, err, &apiErr) + require.Equal(t, "Agent 'test-agent-no-external-agent' does not have an external agent associated with it.", apiErr.Message) + }) } From fb7bc71ce5d80cfa02f9831de5391e7f653c2535 Mon Sep 17 00:00:00 2001 From: Kacper Sawicki Date: Wed, 13 Aug 2025 13:00:06 +0000 Subject: [PATCH 13/14] update external agent handling in workspace agents and adjust Terraform resource references --- enterprise/coderd/workspaceagents.go | 23 ------------------- .../external-agents.tfplan.json | 10 ++++---- .../external-agents.tfstate.json | 6 ++--- .../resources/external-agents/main.tf | 2 +- 4 files changed, 9 insertions(+), 32 deletions(-) diff --git a/enterprise/coderd/workspaceagents.go b/enterprise/coderd/workspaceagents.go index 2a1d448f74df6..0e32d550232f5 100644 --- a/enterprise/coderd/workspaceagents.go +++ b/enterprise/coderd/workspaceagents.go @@ -52,29 +52,6 @@ func (api *API) workspaceExternalAgentCredentials(rw http.ResponseWriter, r *htt return } - resources, err := api.Database.GetWorkspaceResourcesByJobID(ctx, build.JobID) - if err != nil { - httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ - Message: "Failed to get workspace resources.", - Detail: err.Error(), - }) - return - } - - found := false - for _, resource := range resources { - if resource.Type == "coder_external_agent" && resource.Name == agentName { - found = true - break - } - } - if !found { - httpapi.Write(ctx, rw, http.StatusNotFound, codersdk.Response{ - Message: fmt.Sprintf("Agent '%s' does not have an external agent associated with it.", agentName), - }) - return - } - agents, err := api.Database.GetWorkspaceAgentsByWorkspaceAndBuildNumber(ctx, database.GetWorkspaceAgentsByWorkspaceAndBuildNumberParams{ WorkspaceID: workspace.ID, BuildNumber: build.BuildNumber, diff --git a/provisioner/terraform/testdata/resources/external-agents/external-agents.tfplan.json b/provisioner/terraform/testdata/resources/external-agents/external-agents.tfplan.json index 317ef993211cb..3d085a535b2bf 100644 --- a/provisioner/terraform/testdata/resources/external-agents/external-agents.tfplan.json +++ b/provisioner/terraform/testdata/resources/external-agents/external-agents.tfplan.json @@ -43,7 +43,7 @@ "provider_name": "registry.terraform.io/coder/coder", "schema_version": 1, "sensitive_values": { - "token": true + "agent_id": true } } ] @@ -108,12 +108,12 @@ "before": null, "after": {}, "after_unknown": { - "id": true, - "token": true + "agent_id": true, + "id": true }, "before_sensitive": false, "after_sensitive": { - "token": true + "agent_id": true } } } @@ -226,7 +226,7 @@ "name": "dev1", "provider_config_key": "coder", "expressions": { - "token": { + "agent_id": { "references": [ "coder_agent.dev1.token", "coder_agent.dev1" diff --git a/provisioner/terraform/testdata/resources/external-agents/external-agents.tfstate.json b/provisioner/terraform/testdata/resources/external-agents/external-agents.tfstate.json index 807508201ce15..af884a315ec9d 100644 --- a/provisioner/terraform/testdata/resources/external-agents/external-agents.tfstate.json +++ b/provisioner/terraform/testdata/resources/external-agents/external-agents.tfstate.json @@ -122,11 +122,11 @@ "provider_name": "registry.terraform.io/coder/coder", "schema_version": 1, "values": { - "id": "4d87dd70-879c-4347-b0c1-b8f3587d1021", - "token": "d054c66b-cc5c-41ae-aa0c-2098a1075272" + "agent_id": "d054c66b-cc5c-41ae-aa0c-2098a1075272", + "id": "4d87dd70-879c-4347-b0c1-b8f3587d1021" }, "sensitive_values": { - "token": true + "agent_id": 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 index 6e00b81a80dfc..282b77e1474a9 100644 --- a/provisioner/terraform/testdata/resources/external-agents/main.tf +++ b/provisioner/terraform/testdata/resources/external-agents/main.tf @@ -17,5 +17,5 @@ resource "coder_agent" "dev1" { } resource "coder_external_agent" "dev1" { - token = coder_agent.dev1.token + agent_id = coder_agent.dev1.token } From 0baf2b3f6da281dcfdec65af8bac9341b2a04067 Mon Sep 17 00:00:00 2001 From: Kacper Sawicki Date: Wed, 13 Aug 2025 13:22:46 +0000 Subject: [PATCH 14/14] Remove test --- enterprise/coderd/workspaceagents_test.go | 30 ----------------------- 1 file changed, 30 deletions(-) diff --git a/enterprise/coderd/workspaceagents_test.go b/enterprise/coderd/workspaceagents_test.go index a4f676585d413..f7884ef1a66c6 100644 --- a/enterprise/coderd/workspaceagents_test.go +++ b/enterprise/coderd/workspaceagents_test.go @@ -465,34 +465,4 @@ func TestWorkspaceExternalAgentCredentials(t *testing.T) { require.ErrorAs(t, err, &apiErr) require.Equal(t, "Workspace does not have an external agent.", apiErr.Message) }) - - t.Run("No external agent associated with agent - 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, - }).Seed(database.WorkspaceBuild{ - HasExternalAgent: sql.NullBool{ - Bool: true, - Valid: true, - }, - }).Resource(&proto.Resource{ - Name: "test-external-agent", - Type: "coder_external_agent", - }).WithAgent(func(a []*proto.Agent) []*proto.Agent { - a[0].Name = "test-agent-no-external-agent" - a[0].Auth = &proto.Agent_Token{ - Token: uuid.NewString(), - } - return a - }).Do() - - _, err := client.WorkspaceExternalAgentCredentials(ctx, r.Workspace.ID, "test-agent-no-external-agent") - require.Error(t, err) - var apiErr *codersdk.Error - require.ErrorAs(t, err, &apiErr) - require.Equal(t, "Agent 'test-agent-no-external-agent' does not have an external agent associated with it.", apiErr.Message) - }) } pFad - Phonifier reborn

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

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


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy