mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
> [!NOTE] > This PR was authored by Coder Agents on behalf of @jeremyruppel. Part 2 of DEVEX-277 (POST /api/v2/templatebuilder/compose). Adds the core composition and bundling logic for the template builder. `Compose` renders a base template and selected modules into Terraform source files. It validates modules before rendering (rejects duplicates, ConflictsWith violations, unknown IDs, OS incompatibility), then for each module merges manifest defaults with caller-supplied variable overrides and renders the module template. `mergeModuleVariables` fills in defaults for non-computed, non-sensitive variables from the manifest (with basic JSON type validation via `isSimpleJSONValue`), uses `null` for non-required variables without defaults, and leaves required variables absent so `missingkey=error` catches omissions at render time. `BundleTar` packages the result into a tar archive with reproducible timestamps. Writes `main.tf` always, `modules.tf` only when modules are present. Conflict detection is bidirectional so module ordering in the request does not affect validation.
278 lines
7.9 KiB
Go
278 lines
7.9 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.Contains(t, modules, `port = 9999`)
|
|
})
|
|
|
|
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("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
|
|
}
|