From dd7a4f54b1237392dc4e3824078afa42e099ab51 Mon Sep 17 00:00:00 2001 From: Jeremy Ruppel Date: Mon, 1 Jun 2026 14:11:16 +0000 Subject: [PATCH] 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 --- coderd/templatebuilder/catalog.go | 124 +++++++++++++++++ coderd/templatebuilder/catalog_test.go | 129 ++++++++++++++++++ .../templatebuilder/modules/stub/module.json | 29 ++++ codersdk/templatebuilder.go | 42 ++++++ site/src/api/typesGenerated.ts | 50 +++++++ 5 files changed, 374 insertions(+) create mode 100644 coderd/templatebuilder/catalog.go create mode 100644 coderd/templatebuilder/catalog_test.go create mode 100644 coderd/templatebuilder/modules/stub/module.json create mode 100644 codersdk/templatebuilder.go diff --git a/coderd/templatebuilder/catalog.go b/coderd/templatebuilder/catalog.go new file mode 100644 index 0000000000..3b745ebfb9 --- /dev/null +++ b/coderd/templatebuilder/catalog.go @@ -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, + } +} diff --git a/coderd/templatebuilder/catalog_test.go b/coderd/templatebuilder/catalog_test.go new file mode 100644 index 0000000000..cd90010da5 --- /dev/null +++ b/coderd/templatebuilder/catalog_test.go @@ -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{} +} diff --git a/coderd/templatebuilder/modules/stub/module.json b/coderd/templatebuilder/modules/stub/module.json new file mode 100644 index 0000000000..4053697c67 --- /dev/null +++ b/coderd/templatebuilder/modules/stub/module.json @@ -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 + } + ] +} diff --git a/codersdk/templatebuilder.go b/codersdk/templatebuilder.go new file mode 100644 index 0000000000..9cb0f17901 --- /dev/null +++ b/codersdk/templatebuilder.go @@ -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"` +} diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index a42e1489ff..fa4862b043 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -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.