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
+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