feat(coderd/templatebuilder): add Compose and BundleTar functions (#26349)

> [!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.
This commit is contained in:
Jeremy Ruppel
2026-06-15 09:28:39 -04:00
committed by GitHub
parent 6b890116aa
commit 877f4def4a
3 changed files with 632 additions and 0 deletions
@@ -0,0 +1,98 @@
package templatebuilder
import (
"encoding/json"
"testing"
"github.com/stretchr/testify/require"
)
func TestIsSimpleJSONValue(t *testing.T) {
t.Parallel()
tests := []struct {
name string
raw json.RawMessage
want bool
}{
{"String", json.RawMessage(`"hello"`), true},
{"EmptyString", json.RawMessage(`""`), true},
{"True", json.RawMessage(`true`), true},
{"False", json.RawMessage(`false`), true},
{"Null", json.RawMessage(`null`), true},
{"PositiveInt", json.RawMessage(`42`), true},
{"NegativeInt", json.RawMessage(`-1`), true},
{"Float", json.RawMessage(`3.14`), true},
{"Array", json.RawMessage(`[1,2]`), false},
{"Object", json.RawMessage(`{"a":1}`), false},
{"Empty", json.RawMessage(``), false},
{"Nil", nil, false},
{"MalformedString", json.RawMessage(`"unclosed`), false},
{"MalformedBool", json.RawMessage(`truesomething`), false},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
got := isSimpleJSONValue(tc.raw)
require.Equal(t, tc.want, got)
})
}
}
func TestMergeModuleVariables(t *testing.T) {
t.Parallel()
manifest := ModuleManifest{
Variables: []ModuleVariable{
{Name: "agent_id", Type: "string", Computed: true},
{Name: "api_key", Type: "string", Sensitive: true},
{Name: "port", Type: "number", Default: json.RawMessage(`13337`)},
{Name: "enabled", Type: "bool", Default: json.RawMessage(`false`)},
{Name: "optional_no_default", Type: "string", Required: false},
{Name: "required_no_default", Type: "string", Required: true},
},
}
t.Run("DefaultsApplied", func(t *testing.T) {
t.Parallel()
merged := mergeModuleVariables(manifest, nil)
require.Equal(t, "13337", merged["port"])
require.Equal(t, "false", merged["enabled"])
})
t.Run("ComputedAndSensitiveSkipped", func(t *testing.T) {
t.Parallel()
merged := mergeModuleVariables(manifest, nil)
require.NotContains(t, merged, "agent_id")
require.NotContains(t, merged, "api_key")
})
t.Run("NonRequiredWithoutDefaultGetsNull", func(t *testing.T) {
t.Parallel()
merged := mergeModuleVariables(manifest, nil)
require.Equal(t, "null", merged["optional_no_default"])
})
t.Run("RequiredWithoutDefaultOmitted", func(t *testing.T) {
t.Parallel()
merged := mergeModuleVariables(manifest, nil)
require.NotContains(t, merged, "required_no_default")
})
t.Run("CallerOverridesDefault", func(t *testing.T) {
t.Parallel()
merged := mergeModuleVariables(manifest, map[string]string{
"port": "9999",
})
require.Equal(t, "9999", merged["port"])
})
t.Run("CallerProvidesRequired", func(t *testing.T) {
t.Parallel()
merged := mergeModuleVariables(manifest, map[string]string{
"required_no_default": `"value"`,
})
require.Equal(t, `"value"`, merged["required_no_default"])
})
}