feat(coderd/templatebuilder): add module catalog structure and go:embed wiring

Scaffolds the coderd/templatebuilder package for the guided template
builder (DEVEX-272). Adds:

- codersdk.TemplateBuilderModule and related types matching the RFC schema
- ModuleManifest internal type with go:embed wiring for bundled catalog
- LoadModules() to parse module.json files from embedded FS
- ToSDK() conversion from on-disk manifest to API response type
- Stub module exercising all schema fields (builder_managed, sensitive,
  conflicts_with, compatible_os, pinned_version)
- Tests for catalog loading, field parsing, and SDK conversion
This commit is contained in:
Jeremy Ruppel
2026-06-01 14:11:16 +00:00
parent 98c2b60820
commit dd7a4f54b1
5 changed files with 374 additions and 0 deletions
+124
View File
@@ -0,0 +1,124 @@
package templatebuilder
import (
"embed"
"encoding/json"
"io/fs"
"path"
"sync"
"golang.org/x/xerrors"
"github.com/coder/coder/v2/codersdk"
)
var (
//go:embed modules
files embed.FS
catalogOnce sync.Once
catalogModules []ModuleManifest
errCatalogLoad error
)
const modulesDir = "modules"
// ModuleManifest represents a module.json file from the bundled catalog.
// This is the on-disk schema; codersdk.TemplateBuilderModule is the API type.
type ModuleManifest struct {
ID string `json:"id"`
DisplayName string `json:"display_name"`
Description string `json:"description"`
Icon string `json:"icon"`
Category string `json:"category"`
Tags []string `json:"tags"`
CompatibleOS []string `json:"compatible_os"`
ConflictsWith []string `json:"conflicts_with"`
PinnedVersion string `json:"pinned_version"`
Variables []ModuleVariable `json:"variables"`
}
// ModuleVariable represents a variable declaration within a module manifest.
type ModuleVariable struct {
Name string `json:"name"`
Type string `json:"type"`
Description string `json:"description"`
Default *string `json:"default,omitempty"`
Required bool `json:"required"`
Sensitive bool `json:"sensitive"`
BuilderManaged bool `json:"builder_managed"`
}
// LoadModules reads all module.json files from the embedded catalog.
// Results are cached after the first successful call.
func LoadModules() ([]ModuleManifest, error) {
catalogOnce.Do(func() {
catalogModules, errCatalogLoad = parseModules()
})
return catalogModules, errCatalogLoad
}
func parseModules() ([]ModuleManifest, error) {
modulesFS, err := fs.Sub(files, modulesDir)
if err != nil {
return nil, xerrors.Errorf("get modules fs: %w", err)
}
dirs, err := fs.ReadDir(modulesFS, ".")
if err != nil {
return nil, xerrors.Errorf("read modules dir: %w", err)
}
var modules []ModuleManifest
for _, dir := range dirs {
if !dir.IsDir() {
continue
}
manifestPath := path.Join(dir.Name(), "module.json")
data, err := fs.ReadFile(modulesFS, manifestPath)
if err != nil {
// Skip directories without a module.json.
continue
}
var manifest ModuleManifest
if err := json.Unmarshal(data, &manifest); err != nil {
return nil, xerrors.Errorf("decode %s: %w", manifestPath, err)
}
modules = append(modules, manifest)
}
return modules, nil
}
// ToSDK converts a ModuleManifest to the API response type.
// PinnedVersion is mapped to Version; tags are excluded from the
// API surface.
func (m ModuleManifest) ToSDK() codersdk.TemplateBuilderModule {
variables := make([]codersdk.TemplateBuilderModuleVariable, 0, len(m.Variables))
for _, v := range m.Variables {
variables = append(variables, codersdk.TemplateBuilderModuleVariable{
Name: v.Name,
Type: codersdk.TemplateBuilderVariableType(v.Type),
Description: v.Description,
Default: v.Default,
Required: v.Required,
Sensitive: v.Sensitive,
BuilderManaged: v.BuilderManaged,
})
}
return codersdk.TemplateBuilderModule{
ID: m.ID,
DisplayName: m.DisplayName,
Description: m.Description,
Icon: m.Icon,
Category: m.Category,
Version: m.PinnedVersion,
CompatibleOS: m.CompatibleOS,
ConflictsWith: m.ConflictsWith,
Variables: variables,
}
}
+129
View File
@@ -0,0 +1,129 @@
package templatebuilder_test
import (
"testing"
"github.com/stretchr/testify/require"
"github.com/coder/coder/v2/coderd/templatebuilder"
"github.com/coder/coder/v2/codersdk"
)
func TestLoadModules(t *testing.T) {
t.Parallel()
modules, err := templatebuilder.LoadModules()
require.NoError(t, err)
require.NotEmpty(t, modules, "catalog should contain at least the stub module")
// Find the stub module used for testing.
var stub *templatebuilder.ModuleManifest
for i := range modules {
if modules[i].ID == "stub" {
stub = &modules[i]
break
}
}
require.NotNil(t, stub, "stub module must be present in the catalog")
t.Run("ManifestFields", func(t *testing.T) {
t.Parallel()
require.Equal(t, "stub", stub.ID)
require.Equal(t, "Stub Module", stub.DisplayName)
require.Equal(t, "Utility", stub.Category)
require.Equal(t, "0.0.0", stub.PinnedVersion)
require.Equal(t, []string{"linux"}, stub.CompatibleOS)
require.Empty(t, stub.ConflictsWith)
require.Len(t, stub.Variables, 2)
})
t.Run("BuilderManagedVariable", func(t *testing.T) {
t.Parallel()
agentVar := findVariable(t, stub.Variables, "agent_id")
require.True(t, agentVar.BuilderManaged, "agent_id should be builder_managed")
require.True(t, agentVar.Required)
require.False(t, agentVar.Sensitive)
require.Equal(t, "string", agentVar.Type)
})
t.Run("SensitiveVariable", func(t *testing.T) {
t.Parallel()
secretVar := findVariable(t, stub.Variables, "example_secret")
require.True(t, secretVar.Sensitive, "example_secret should be sensitive")
require.False(t, secretVar.BuilderManaged)
require.False(t, secretVar.Required)
require.Equal(t, "string", secretVar.Type)
})
}
func TestToSDK(t *testing.T) {
t.Parallel()
modules, err := templatebuilder.LoadModules()
require.NoError(t, err)
var stub templatebuilder.ModuleManifest
for _, m := range modules {
if m.ID == "stub" {
stub = m
break
}
}
require.NotEmpty(t, stub.ID, "stub module must be present")
sdk := stub.ToSDK()
t.Run("PinnedVersionMapsToVersion", func(t *testing.T) {
t.Parallel()
require.Equal(t, stub.PinnedVersion, sdk.Version)
require.Equal(t, "0.0.0", sdk.Version)
})
t.Run("FieldsPreserved", func(t *testing.T) {
t.Parallel()
require.Equal(t, stub.ID, sdk.ID)
require.Equal(t, stub.DisplayName, sdk.DisplayName)
require.Equal(t, stub.Description, sdk.Description)
require.Equal(t, stub.Category, sdk.Category)
require.Equal(t, stub.CompatibleOS, sdk.CompatibleOS)
require.Equal(t, stub.ConflictsWith, sdk.ConflictsWith)
})
t.Run("VariablesConverted", func(t *testing.T) {
t.Parallel()
require.Len(t, sdk.Variables, 2)
agentVar := findSDKVariable(t, sdk.Variables, "agent_id")
require.Equal(t, codersdk.TemplateBuilderVariableTypeString, agentVar.Type)
require.True(t, agentVar.BuilderManaged)
secretVar := findSDKVariable(t, sdk.Variables, "example_secret")
require.True(t, secretVar.Sensitive)
require.False(t, secretVar.BuilderManaged)
})
}
func findVariable(t *testing.T, vars []templatebuilder.ModuleVariable, name string) templatebuilder.ModuleVariable {
t.Helper()
for _, v := range vars {
if v.Name == name {
return v
}
}
t.Fatalf("variable %q not found", name)
return templatebuilder.ModuleVariable{}
}
func findSDKVariable(t *testing.T, vars []codersdk.TemplateBuilderModuleVariable, name string) codersdk.TemplateBuilderModuleVariable {
t.Helper()
for _, v := range vars {
if v.Name == name {
return v
}
}
t.Fatalf("variable %q not found", name)
return codersdk.TemplateBuilderModuleVariable{}
}
@@ -0,0 +1,29 @@
{
"id": "stub",
"display_name": "Stub Module",
"description": "Stub module for testing the catalog system.",
"icon": "",
"category": "Utility",
"tags": ["test"],
"compatible_os": ["linux"],
"conflicts_with": [],
"pinned_version": "0.0.0",
"variables": [
{
"name": "agent_id",
"type": "string",
"description": "The ID of the Coder agent.",
"required": true,
"sensitive": false,
"builder_managed": true
},
{
"name": "example_secret",
"type": "string",
"description": "An example sensitive variable.",
"required": false,
"sensitive": true,
"builder_managed": false
}
]
}
+42
View File
@@ -0,0 +1,42 @@
package codersdk
// TemplateBuilderVariableType represents the type of a template builder variable.
type TemplateBuilderVariableType string
const (
TemplateBuilderVariableTypeString TemplateBuilderVariableType = "string"
TemplateBuilderVariableTypeNumber TemplateBuilderVariableType = "number"
TemplateBuilderVariableTypeBool TemplateBuilderVariableType = "bool"
)
// TemplateBuilderModuleVariable represents a variable within a template builder module.
type TemplateBuilderModuleVariable struct {
Name string `json:"name"`
Type TemplateBuilderVariableType `json:"type"`
Description string `json:"description"`
Default *string `json:"default,omitempty"`
Required bool `json:"required"`
Sensitive bool `json:"sensitive"`
BuilderManaged bool `json:"builder_managed"`
}
// TemplateBuilderModule is the API response type returned by
// GET /api/v2/templatebuilder/modules. The Version field is
// populated from the catalog's PinnedVersion at serving time.
type TemplateBuilderModule struct {
ID string `json:"id"`
DisplayName string `json:"display_name"`
Description string `json:"description"`
Icon string `json:"icon"`
Category string `json:"category"`
Version string `json:"version"`
CompatibleOS []string `json:"compatible_os"`
ConflictsWith []string `json:"conflicts_with"`
Variables []TemplateBuilderModuleVariable `json:"variables"`
}
// TemplateBuilderModulesResponse is the response body for listing
// template builder modules.
type TemplateBuilderModulesResponse struct {
Modules []TemplateBuilderModule `json:"modules"`
}
+50
View File
@@ -8127,6 +8127,56 @@ export interface TemplateBuilderConfig {
readonly registry_url?: string;
}
// From codersdk/templatebuilder.go
/**
* TemplateBuilderModule is the API response type returned by
* GET /api/v2/templatebuilder/modules. The Version field is
* populated from the catalog's PinnedVersion at serving time.
*/
export interface TemplateBuilderModule {
readonly id: string;
readonly display_name: string;
readonly description: string;
readonly icon: string;
readonly category: string;
readonly version: string;
readonly compatible_os: readonly string[];
readonly conflicts_with: readonly string[];
readonly variables: readonly TemplateBuilderModuleVariable[];
}
// From codersdk/templatebuilder.go
/**
* TemplateBuilderModuleVariable represents a variable within a template builder module.
*/
export interface TemplateBuilderModuleVariable {
readonly name: string;
readonly type: TemplateBuilderVariableType;
readonly description: string;
readonly default?: string;
readonly required: boolean;
readonly sensitive: boolean;
readonly builder_managed: boolean;
}
// From codersdk/templatebuilder.go
/**
* TemplateBuilderModulesResponse is the response body for listing
* template builder modules.
*/
export interface TemplateBuilderModulesResponse {
readonly modules: readonly TemplateBuilderModule[];
}
// From codersdk/templatebuilder.go
export type TemplateBuilderVariableType = "bool" | "number" | "string";
export const TemplateBuilderVariableTypes: TemplateBuilderVariableType[] = [
"bool",
"number",
"string",
];
// From codersdk/insights.go
/**
* Enums define the display name of the builtin app reported.