Files
coder/coderd/templatebuilder/compose_test.go
T
Jeremy Ruppel de31c7c18e feat: add TemplateBuilderCreateTemplate SDK types and client method (#26360)
Adds `POST /api/v2/templatebuilder/compose/template`, a synchronous
endpoint that composes a template from a base and modules, validates it
via a provisioner import job, and creates the template in a single
request.

The handler composes terraform files, bundles them as a tar, inserts the
file with hash-based dedup, creates a template version with an import
job, waits up to 2 minutes for the job to complete, classifies errors
for known failure modes (network-unreachable registry, DNS failures),
then creates the template on success. Canceled and failed jobs return
appropriate error responses.

Also adds `hclwrite.Format` to composed terraform output for canonical
HCL formatting.

Closes https://linear.app/codercom/issue/DEVEX-279

<details>
<summary>Implementation notes</summary>

- SDK types and client method in `codersdk/templatebuilder.go` with
validation tags matching the standard template creation path
(`template_display_name`, `lt=128`)
- `ClassifyProvisionerError` in `coderd/templatebuilder/errors.go`
detects DNS, connection refused, i/o timeout, and TLS handshake failures
and returns actionable messages
- `waitForProvisionerJob` polls with a ramp-up interval schedule (100ms,
200ms, 500ms, then 1s steady) and accepts an `onUpdate` callback for
future SSE streaming
- Audit logging for both template and template version creation
- TOCTOU name uniqueness: early check for fast feedback, DB unique
constraint catch for the race window (returns 409, not 500)
- Swagger annotations for all error responses (400, 404, 409, 504)

</details>

> 🤖 Generated by Coder Agents
2026-06-15 18:12:55 -04:00

334 lines
9.4 KiB
Go

package templatebuilder_test
import (
"archive/tar"
"bytes"
"errors"
"io"
"testing"
"github.com/stretchr/testify/require"
"github.com/coder/coder/v2/coderd/templatebuilder"
)
func TestCompose(t *testing.T) {
t.Parallel()
t.Run("BaseOnly", func(t *testing.T) {
t.Parallel()
result, err := templatebuilder.Compose(templatebuilder.ComposeRequest{
BaseTemplateID: "docker",
RegistryURL: "https://registry.coder.com",
})
require.NoError(t, err)
require.NotEmpty(t, result.MainTF)
require.Contains(t, string(result.MainTF), `resource "coder_agent" "main"`)
require.Empty(t, result.ModulesTF)
})
t.Run("BaseWithModuleAndVariableOverride", func(t *testing.T) {
t.Parallel()
result, err := templatebuilder.Compose(templatebuilder.ComposeRequest{
BaseTemplateID: "docker",
RegistryURL: "https://registry.coder.com",
Modules: []templatebuilder.ComposeModule{
{
ID: "code-server",
Variables: map[string]string{
"port": "9999",
},
},
},
})
require.NoError(t, err)
require.NotEmpty(t, result.MainTF)
require.NotEmpty(t, result.ModulesTF)
modules := string(result.ModulesTF)
require.Contains(t, modules, `module "code-server"`)
require.Contains(t, modules, `coder_agent.main.id`)
require.Contains(t, modules, `registry.coder.com`)
require.Regexp(t, `port\s+=\s+9999`, modules)
})
t.Run("AWSLinuxAgentName", func(t *testing.T) {
t.Parallel()
result, err := templatebuilder.Compose(templatebuilder.ComposeRequest{
BaseTemplateID: "aws-linux",
RegistryURL: "https://registry.coder.com",
Modules: []templatebuilder.ComposeModule{
{ID: "git-commit-signing"},
},
})
require.NoError(t, err)
require.Contains(t, string(result.ModulesTF), `coder_agent.dev.id`)
})
t.Run("SensitiveVariable", func(t *testing.T) {
t.Parallel()
result, err := templatebuilder.Compose(templatebuilder.ComposeRequest{
BaseTemplateID: "docker",
RegistryURL: "https://registry.coder.com",
Modules: []templatebuilder.ComposeModule{
{ID: "claude-code"},
},
})
require.NoError(t, err)
modules := string(result.ModulesTF)
// claude-code has a sensitive variable (claude_code_oauth_token)
// that renders as a top-level variable block + var. reference.
require.Contains(t, modules, `variable "claude_code_oauth_token"`)
require.Contains(t, modules, `sensitive = true`)
require.Contains(t, modules, `var.claude_code_oauth_token`)
})
t.Run("MultipleModulesWithRequiredVariable", func(t *testing.T) {
t.Parallel()
result, err := templatebuilder.Compose(templatebuilder.ComposeRequest{
BaseTemplateID: "docker",
RegistryURL: "https://registry.coder.com",
Modules: []templatebuilder.ComposeModule{
{ID: "code-server"},
{
ID: "git-clone",
Variables: map[string]string{
"url": `"https://github.com/coder/coder"`,
},
},
},
})
require.NoError(t, err)
modules := string(result.ModulesTF)
require.Contains(t, modules, `module "code-server"`)
require.Contains(t, modules, `module "git-clone"`)
require.Contains(t, modules, `"https://github.com/coder/coder"`)
})
t.Run("CustomRegistryURL", func(t *testing.T) {
t.Parallel()
result, err := templatebuilder.Compose(templatebuilder.ComposeRequest{
BaseTemplateID: "docker",
RegistryURL: "https://registry.internal.corp",
Modules: []templatebuilder.ComposeModule{
{ID: "code-server"},
},
})
require.NoError(t, err)
require.Contains(t, string(result.ModulesTF), `registry.internal.corp`)
})
t.Run("DuplicateModuleError", func(t *testing.T) {
t.Parallel()
_, err := templatebuilder.Compose(templatebuilder.ComposeRequest{
BaseTemplateID: "docker",
RegistryURL: "https://registry.coder.com",
Modules: []templatebuilder.ComposeModule{
{ID: "code-server"},
{ID: "code-server"},
},
})
require.Error(t, err)
require.Contains(t, err.Error(), `duplicate module "code-server"`)
})
t.Run("ConflictingModuleError", func(t *testing.T) {
t.Parallel()
_, err := templatebuilder.Compose(templatebuilder.ComposeRequest{
BaseTemplateID: "docker",
RegistryURL: "https://registry.coder.com",
Modules: []templatebuilder.ComposeModule{
{ID: "code-server"},
{ID: "vscode-web"},
},
})
require.Error(t, err)
require.Contains(t, err.Error(), "conflicts with")
})
t.Run("UnknownBase", func(t *testing.T) {
t.Parallel()
_, err := templatebuilder.Compose(templatebuilder.ComposeRequest{
BaseTemplateID: "nonexistent",
RegistryURL: "https://registry.coder.com",
})
require.Error(t, err)
require.Contains(t, err.Error(), "unknown base template")
})
t.Run("UnknownModule", func(t *testing.T) {
t.Parallel()
_, err := templatebuilder.Compose(templatebuilder.ComposeRequest{
BaseTemplateID: "docker",
RegistryURL: "https://registry.coder.com",
Modules: []templatebuilder.ComposeModule{
{ID: "nonexistent-module"},
},
})
require.Error(t, err)
require.Contains(t, err.Error(), `unknown module "nonexistent-module"`)
})
t.Run("UnknownVariableKeyRejected", func(t *testing.T) {
t.Parallel()
_, err := templatebuilder.Compose(templatebuilder.ComposeRequest{
BaseTemplateID: "docker",
RegistryURL: "https://registry.coder.com",
Modules: []templatebuilder.ComposeModule{
{
ID: "code-server",
Variables: map[string]string{
"nonexistent_var": `"value"`,
},
},
},
})
require.Error(t, err)
require.Contains(t, err.Error(), `module "code-server"`)
require.Contains(t, err.Error(), `unknown variable "nonexistent_var"`)
})
t.Run("InvalidVariableValueRejected", func(t *testing.T) {
t.Parallel()
_, err := templatebuilder.Compose(templatebuilder.ComposeRequest{
BaseTemplateID: "docker",
RegistryURL: "https://registry.coder.com",
Modules: []templatebuilder.ComposeModule{
{
ID: "code-server",
Variables: map[string]string{
"port": "not-a-number",
},
},
},
})
require.Error(t, err)
require.Contains(t, err.Error(), `module "code-server"`)
require.Contains(t, err.Error(), `variable "port"`)
})
t.Run("HCLInjectionRejected", func(t *testing.T) {
t.Parallel()
_, err := templatebuilder.Compose(templatebuilder.ComposeRequest{
BaseTemplateID: "docker",
RegistryURL: "https://registry.coder.com",
Modules: []templatebuilder.ComposeModule{
{
ID: "code-server",
Variables: map[string]string{
"folder": `"${var.evil}"`,
},
},
},
})
require.Error(t, err)
require.Contains(t, err.Error(), "interpolation")
})
t.Run("MissingRequiredVariable", func(t *testing.T) {
t.Parallel()
// git-clone has a required "url" variable with no default.
// Omitting it should cause a render error from missingkey=error.
_, err := templatebuilder.Compose(templatebuilder.ComposeRequest{
BaseTemplateID: "docker",
RegistryURL: "https://registry.coder.com",
Modules: []templatebuilder.ComposeModule{
{ID: "git-clone"},
},
})
require.Error(t, err)
require.Contains(t, err.Error(), "render module")
})
}
func TestBundleTar(t *testing.T) {
t.Parallel()
t.Run("NilResult", func(t *testing.T) {
t.Parallel()
_, err := templatebuilder.BundleTar(nil)
require.Error(t, err)
require.Contains(t, err.Error(), "nil")
})
t.Run("MainOnly", func(t *testing.T) {
t.Parallel()
result := &templatebuilder.ComposeResult{
MainTF: []byte("resource {}"),
}
data, err := templatebuilder.BundleTar(result)
require.NoError(t, err)
files := extractTar(t, data)
require.Contains(t, files, "main.tf")
require.NotContains(t, files, "modules.tf")
require.Equal(t, "resource {}", files["main.tf"])
})
t.Run("MainAndModules", func(t *testing.T) {
t.Parallel()
result := &templatebuilder.ComposeResult{
MainTF: []byte("resource {}"),
ModulesTF: []byte("module {}"),
}
data, err := templatebuilder.BundleTar(result)
require.NoError(t, err)
files := extractTar(t, data)
require.Contains(t, files, "main.tf")
require.Contains(t, files, "modules.tf")
require.Equal(t, "resource {}", files["main.tf"])
require.Equal(t, "module {}", files["modules.tf"])
})
t.Run("RoundTrip", func(t *testing.T) {
t.Parallel()
result, err := templatebuilder.Compose(templatebuilder.ComposeRequest{
BaseTemplateID: "docker",
RegistryURL: "https://registry.coder.com",
Modules: []templatebuilder.ComposeModule{
{ID: "code-server"},
},
})
require.NoError(t, err)
data, err := templatebuilder.BundleTar(result)
require.NoError(t, err)
files := extractTar(t, data)
require.Equal(t, string(result.MainTF), files["main.tf"])
require.Equal(t, string(result.ModulesTF), files["modules.tf"])
})
t.Run("ReproducibleArchive", func(t *testing.T) {
t.Parallel()
result := &templatebuilder.ComposeResult{
MainTF: []byte("resource {}"),
ModulesTF: []byte("module {}"),
}
data1, err := templatebuilder.BundleTar(result)
require.NoError(t, err)
data2, err := templatebuilder.BundleTar(result)
require.NoError(t, err)
require.Equal(t, data1, data2, "identical inputs should produce identical archives")
})
}
// extractTar reads a tar archive and returns a map of filename to content.
func extractTar(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
}