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
+2 -1
View File
@@ -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,
+1 -1
View File
@@ -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"
],
+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
+4 -1
View File
@@ -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]
}
+28
View File
@@ -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
}
+2
View File
@@ -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 {
+1
View File
@@ -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:
+20
View File
@@ -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)
}
+2 -14
View File
@@ -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"
},
{
+6
View File
@@ -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",
+39
View File
@@ -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).
+26
View File
@@ -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 | |
+22
View File
@@ -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
@@ -7614,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": [
@@ -7678,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 | | |
@@ -8813,6 +8831,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 +9942,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 +10152,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 +10692,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",
+9
View File
@@ -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": [
+11 -5
View File
@@ -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",
+49 -37
View File
@@ -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 {
@@ -920,17 +929,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
})
@@ -939,9 +940,17 @@ 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 {
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.
@@ -951,32 +960,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.Entitlements.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{
+57 -6
View File
@@ -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.
+33
View File
@@ -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 {
+79
View File
@@ -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,77 @@ 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
}
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,
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,
})
}
+122
View File
@@ -3,6 +3,7 @@ package coderd_test
import (
"context"
"crypto/tls"
"database/sql"
"fmt"
"net/http"
"os"
@@ -12,6 +13,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 +346,123 @@ 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,
}).Seed(database.WorkspaceBuild{
HasExternalAgent: sql.NullBool{
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"
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,
}).Resource(&proto.Resource{
Name: "test-agent",
Type: "coder_external_agent",
}).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"
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,
}).Seed(database.WorkspaceBuild{
HasExternalAgent: sql.NullBool{
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{
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)
})
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)
})
}
+1
View File
@@ -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
+26
View File
@@ -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 {
+18 -1
View File
@@ -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{
+29
View File
@@ -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) {
@@ -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)"
}
}
@@ -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": {
"agent_id": 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": {
"agent_id": true,
"id": true
},
"before_sensitive": false,
"after_sensitive": {
"agent_id": 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": {
"agent_id": {
"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
}
@@ -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)"
}
}
@@ -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": {
"agent_id": "d054c66b-cc5c-41ae-aa0c-2098a1075272",
"id": "4d87dd70-879c-4347-b0c1-b8f3587d1021"
},
"sensitive_values": {
"agent_id": true
},
"depends_on": [
"coder_agent.dev1"
]
}
]
}
}
}
@@ -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" {
agent_id = coder_agent.dev1.token
}
+1 -1
View File
@@ -1 +1 @@
1.11.4
1.12.2
+14 -3
View File
@@ -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,
+1
View File
@@ -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;
+4 -1
View File
@@ -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.
+5 -2
View File
@@ -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",
+15 -4
View File
@@ -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,
+1
View File
@@ -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
+4
View File
@@ -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;
},
};
+10
View File
@@ -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;
@@ -1052,6 +1058,7 @@ export type FeatureName =
| "user_limit"
| "user_role_management"
| "workspace_batch_actions"
| "workspace_external_agent"
| "workspace_prebuilds"
| "workspace_proxy";
@@ -1075,6 +1082,7 @@ export const FeatureNames: FeatureName[] = [
"user_limit",
"user_role_management",
"workspace_batch_actions",
"workspace_external_agent",
"workspace_prebuilds",
"workspace_proxy",
];
@@ -3000,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
@@ -3876,6 +3885,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
+2
View File
@@ -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 =