Files
coder/coderd/templatebuilder_compose_test.go
T
Jeremy RuppelandCoder Agents 8b8c80614d feat: expose template builder agents and per-module agent (#28905)
Part of [DEVEX-556](https://linear.app/codercom/issue/DEVEX-556). Builds
on the base agent metadata added in #28794.

## Summary

Surfaces the backend multi-agent support through the SDK and HTTP API:
the bases endpoint returns each base's agents, and
compose/create-template accept a per-module `agent_name`.

## What changed

- `codersdk/templatebuilder.go`: added `TemplateBuilderBaseAgent`,
`TemplateBuilderBase.Agents`, and
`TemplateBuilderComposeModule.AgentName` (`agent_name,omitempty`).
- `coderd/templatebuilder_handler.go`: added `baseAgentsToSDK`; the
bases endpoint returns agents, and the compose and create-template
endpoints forward `AgentName`.
- `make gen` regenerated `site/src/api/typesGenerated.ts`,
`coderd/apidoc/docs.go`, `coderd/apidoc/swagger.json`,
`docs/reference/api/schemas.md`, and
`docs/reference/api/templatebuilder.md`.
- `wizardState.test.ts`: added `agents: []` to the base fixture now that
the field is required on the generated type.

## Scope

SDK/API plumbing only. `ComposeModule.AgentName` now has a producer (the
handlers) but no caller sets it yet — the wizard UI (follow-up) is what
will populate it. No DB change, so `enterprise/audit/table.go` is
unaffected.

## Testing

- Bases endpoint asserts each base returns agents with exactly one
default (and the `dev`/`main` names).
- Per-module `agent_name` round-trip and unknown-agent (400) compose
tests.
- `make gen` (idempotent), `make fmt` (no changes), `golangci-lint
./coderd/... ./codersdk/...`, the Go template-builder tests, frontend
`tsc` + `biome check`, and docs markdownlint all pass.

<details>
<summary>Design note</summary>

`display_name` is intentionally returned as `""` for now: every base is
single-agent, so no base authors a per-agent label. The field exists for
multi-agent bases where the wizard radio needs a human-readable name;
consumers fall back to the agent `name` when it is empty.

</details>

---
🤖 Generated by Coder Agents on behalf of @jeremyruppel.

Co-authored-by: Coder Agents <agents@coder.com>
2026-09-02 12:51:33 -04:00

203 lines
5.6 KiB
Go

package coderd_test
import (
"archive/tar"
"bytes"
"context"
"errors"
"io"
"net/http"
"testing"
"github.com/stretchr/testify/require"
"github.com/coder/coder/v2/coderd/coderdtest"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/testutil"
)
func TestTemplateBuilderCompose(t *testing.T) {
t.Parallel()
t.Run("BaseOnly", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, nil)
_ = coderdtest.CreateFirstUser(t, client)
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
tarData, err := client.TemplateBuilderCompose(ctx, codersdk.TemplateBuilderComposeRequest{
BaseTemplateID: "docker",
})
require.NoError(t, err)
require.NotEmpty(t, tarData)
files := extractTarFiles(t, tarData)
require.Contains(t, files, "main.tf")
require.NotContains(t, files, "modules.tf")
require.Contains(t, files["main.tf"], `resource "coder_agent"`)
})
t.Run("BaseWithModules", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, nil)
_ = coderdtest.CreateFirstUser(t, client)
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
tarData, err := client.TemplateBuilderCompose(ctx, codersdk.TemplateBuilderComposeRequest{
BaseTemplateID: "docker",
Modules: []codersdk.TemplateBuilderComposeModule{
{ID: "code-server"},
{
ID: "git-clone",
Variables: map[string]string{
"url": `"https://github.com/coder/coder"`,
},
},
},
})
require.NoError(t, err)
files := extractTarFiles(t, tarData)
require.Contains(t, files, "main.tf")
require.Contains(t, files, "modules.tf")
require.Contains(t, files["modules.tf"], `module "code-server"`)
require.Contains(t, files["modules.tf"], `module "git-clone"`)
require.Contains(t, files["modules.tf"], `coder_agent.main.id`)
})
t.Run("PerModuleAgentName", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, nil)
_ = coderdtest.CreateFirstUser(t, client)
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
tarData, err := client.TemplateBuilderCompose(ctx, codersdk.TemplateBuilderComposeRequest{
BaseTemplateID: "docker",
Modules: []codersdk.TemplateBuilderComposeModule{
{ID: "code-server", AgentName: "main"},
},
})
require.NoError(t, err)
files := extractTarFiles(t, tarData)
require.Contains(t, files["modules.tf"], `coder_agent.main.id`)
})
t.Run("UnknownAgentName", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, nil)
_ = coderdtest.CreateFirstUser(t, client)
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
_, err := client.TemplateBuilderCompose(ctx, codersdk.TemplateBuilderComposeRequest{
BaseTemplateID: "docker",
Modules: []codersdk.TemplateBuilderComposeModule{
{ID: "code-server", AgentName: "nonexistent"},
},
})
require.Error(t, err)
var sdkErr *codersdk.Error
require.ErrorAs(t, err, &sdkErr)
require.Equal(t, http.StatusBadRequest, sdkErr.StatusCode())
})
t.Run("UnknownBase", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, nil)
_ = coderdtest.CreateFirstUser(t, client)
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
_, err := client.TemplateBuilderCompose(ctx, codersdk.TemplateBuilderComposeRequest{
BaseTemplateID: "nonexistent",
})
require.Error(t, err)
var sdkErr *codersdk.Error
require.ErrorAs(t, err, &sdkErr)
require.Equal(t, http.StatusBadRequest, sdkErr.StatusCode())
})
t.Run("UnknownModule", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, nil)
_ = coderdtest.CreateFirstUser(t, client)
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
_, err := client.TemplateBuilderCompose(ctx, codersdk.TemplateBuilderComposeRequest{
BaseTemplateID: "docker",
Modules: []codersdk.TemplateBuilderComposeModule{
{ID: "nonexistent-module"},
},
})
require.Error(t, err)
var sdkErr *codersdk.Error
require.ErrorAs(t, err, &sdkErr)
require.Equal(t, http.StatusBadRequest, sdkErr.StatusCode())
})
t.Run("MissingBaseTemplateID", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, nil)
_ = coderdtest.CreateFirstUser(t, client)
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
_, err := client.TemplateBuilderCompose(ctx, codersdk.TemplateBuilderComposeRequest{})
require.Error(t, err)
var sdkErr *codersdk.Error
require.ErrorAs(t, err, &sdkErr)
require.Equal(t, http.StatusBadRequest, sdkErr.StatusCode())
})
t.Run("DisabledReturns404", func(t *testing.T) {
t.Parallel()
dv := coderdtest.DeploymentValues(t)
dv.TemplateBuilder.Disabled = true
client := coderdtest.New(t, &coderdtest.Options{
DeploymentValues: dv,
})
_ = coderdtest.CreateFirstUser(t, client)
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
_, err := client.TemplateBuilderCompose(ctx, codersdk.TemplateBuilderComposeRequest{
BaseTemplateID: "docker",
})
require.Error(t, err)
var sdkErr *codersdk.Error
require.ErrorAs(t, err, &sdkErr)
require.Equal(t, http.StatusNotFound, sdkErr.StatusCode())
})
}
func extractTarFiles(t *testing.T, data []byte) map[string]string {
t.Helper()
tr := tar.NewReader(bytes.NewReader(data))
files := make(map[string]string)
for {
hdr, err := tr.Next()
if errors.Is(err, io.EOF) {
break
}
require.NoError(t, err)
body, err := io.ReadAll(tr)
require.NoError(t, err)
files[hdr.Name] = string(body)
}
return files
}