feat(coderd): add support for external agents to API's and provisioner (#19286)

This pull request introduces support for external workspace management, allowing users to register and manage workspaces that are provisioned and managed outside of the Coder.

Depends on: https://github.com/coder/terraform-provider-coder/pull/424

* GET /api/v2/init-script - Gets the agent initialization script
  * By default, it returns a script for Linux (amd64), but with query parameters (os and arch) you can get the init script for different platforms
* GET /api/v2/workspaces/{workspace}/external-agent/{agent}/credentials - Gets credentials for an external agent **(enterprise)**
* Updated queries to filter workspaces/templates by the has_external_agent field
This commit is contained in:
Kacper Sawicki
2025-08-19 10:41:33 +02:00
committed by GitHub
parent f085c37af3
commit 9edceef0bf
53 changed files with 1616 additions and 98 deletions
+93 -1
View File
@@ -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": [
"Enterprise"
],
"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": {
@@ -16816,6 +16902,9 @@ const docTemplate = `{
"created_by": {
"$ref": "#/definitions/codersdk.MinimalUser"
},
"has_external_agent": {
"type": "boolean"
},
"id": {
"type": "string",
"format": "uuid"
@@ -18675,6 +18764,9 @@ const docTemplate = `{
"has_ai_task": {
"type": "boolean"
},
"has_external_agent": {
"type": "boolean"
},
"id": {
"type": "string",
"format": "uuid"
+85 -1
View File
@@ -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": ["Enterprise"],
"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": {
@@ -15337,6 +15415,9 @@
"created_by": {
"$ref": "#/definitions/codersdk.MinimalUser"
},
"has_external_agent": {
"type": "boolean"
},
"id": {
"type": "string",
"format": "uuid"
@@ -17079,6 +17160,9 @@
"has_ai_task": {
"type": "boolean"
},
"has_external_agent": {
"type": "boolean"
},
"id": {
"type": "string",
"format": "uuid"
+3
View File
@@ -1566,6 +1566,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 {
+4 -2
View File
@@ -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
}
+6
View File
@@ -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
}
+45
View File
@@ -0,0 +1,45 @@
package coderd
import (
"crypto/sha256"
"encoding/base64"
"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")
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(scriptBytes)
}
+67
View File
@@ -0,0 +1,67 @@
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 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 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) {
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)
})
}
@@ -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
+11 -9
View File
@@ -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 {
+60
View File
@@ -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",
+56
View File
@@ -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)
}
+1
View File
@@ -1963,6 +1963,7 @@ func convertTemplateVersion(version database.TemplateVersion, job codersdk.Provi
Archived: version.Archived,
Warnings: warnings,
MatchedProvisioners: matchedProvisioners,
HasExternalAgent: version.HasExternalAgent.Bool,
}
}
+33
View File
@@ -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)
}
+6
View File
@@ -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
}
+1 -1
View File
@@ -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