diff --git a/coderd/templatebuilder/catalog.go b/coderd/templatebuilder/catalog.go new file mode 100644 index 0000000000..11d8b59da6 --- /dev/null +++ b/coderd/templatebuilder/catalog.go @@ -0,0 +1,177 @@ +package templatebuilder + +import ( + "bytes" + "embed" + "encoding/json" + "io/fs" + "path" + "sync" + + "golang.org/x/xerrors" + + "github.com/coder/coder/v2/codersdk" +) + +var ( + //go:embed modules + modulesFS embed.FS + + loadModules = sync.OnceValues(func() ([]ModuleManifest, error) { + return parseModulesFromFS(modulesFS) + }) +) + +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 json.RawMessage `json:"default,omitempty"` + Required bool `json:"required"` + Sensitive bool `json:"sensitive"` + Computed bool `json:"computed"` +} + +// validVariableTypes maps module.json type strings to their SDK equivalents. +// Used both for validation in parseModulesFromFS and for conversion in ToSDK. +var validVariableTypes = map[string]codersdk.TemplateBuilderVariableType{ + "string": codersdk.TemplateBuilderVariableTypeString, + "number": codersdk.TemplateBuilderVariableTypeNumber, + "bool": codersdk.TemplateBuilderVariableTypeBool, +} + +// LoadModules returns all module manifests from the embedded catalog. +// Results are cached after the first call, including errors. Each call +// returns a fresh slice so callers can filter or sort without corrupting +// the cache. +func LoadModules() ([]ModuleManifest, error) { + modules, err := loadModules() + if err != nil { + return nil, err + } + out := make([]ModuleManifest, len(modules)) + copy(out, modules) + return out, nil +} + +// parseModulesFromFS reads and validates all module.json files from the +// given filesystem. Most callers should use LoadModules, which reads from +// the embedded catalog. +func parseModulesFromFS(fsys fs.FS) ([]ModuleManifest, error) { + sub, err := fs.Sub(fsys, modulesDir) + if err != nil { + return nil, xerrors.Errorf("open embedded module catalog: %w", err) + } + + dirs, err := fs.ReadDir(sub, ".") + if err != nil { + return nil, xerrors.Errorf("list module catalog entries: %w", err) + } + + seen := make(map[string]bool) + var modules []ModuleManifest + for _, dir := range dirs { + if !dir.IsDir() { + continue + } + + manifestPath := path.Join(dir.Name(), "module.json") + data, err := fs.ReadFile(sub, manifestPath) + if err != nil { + return nil, xerrors.Errorf("read %s: %w", manifestPath, err) + } + + var manifest ModuleManifest + dec := json.NewDecoder(bytes.NewReader(data)) + dec.DisallowUnknownFields() + if err := dec.Decode(&manifest); err != nil { + return nil, xerrors.Errorf("decode %s: %w", manifestPath, err) + } + + if manifest.ID == "" { + return nil, xerrors.Errorf("module in %s has empty id", dir.Name()) + } + if manifest.PinnedVersion == "" { + return nil, xerrors.Errorf("module %q has empty pinned_version", manifest.ID) + } + if seen[manifest.ID] { + return nil, xerrors.Errorf("duplicate module id %q", manifest.ID) + } + seen[manifest.ID] = true + + seenVars := make(map[string]bool) + for i, v := range manifest.Variables { + if v.Name == "" { + return nil, xerrors.Errorf("module %q variable %d has empty name", manifest.ID, i) + } + if seenVars[v.Name] { + return nil, xerrors.Errorf("module %q has duplicate variable name %q", manifest.ID, v.Name) + } + seenVars[v.Name] = true + if _, ok := validVariableTypes[v.Type]; !ok { + return nil, xerrors.Errorf("module %q variable %d (%q): unknown type %q", manifest.ID, i, v.Name, v.Type) + } + } + + modules = append(modules, manifest) + } + + return modules, nil +} + +// ToSDK converts a ModuleManifest to the API response type. +// PinnedVersion is mapped to Version; tags are not part of 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: validVariableTypes[v.Type], + Description: v.Description, + Default: v.Default, + Required: v.Required, + Sensitive: v.Sensitive, + Computed: v.Computed, + }) + } + + // CLEANUP: json/v2 + compatibleOS := m.CompatibleOS + if compatibleOS == nil { + compatibleOS = []string{} + } + conflictsWith := m.ConflictsWith + if conflictsWith == nil { + conflictsWith = []string{} + } + + return codersdk.TemplateBuilderModule{ + ID: m.ID, + DisplayName: m.DisplayName, + Description: m.Description, + Icon: m.Icon, + Category: m.Category, + Version: m.PinnedVersion, + CompatibleOS: compatibleOS, + ConflictsWith: conflictsWith, + Variables: variables, + } +} diff --git a/coderd/templatebuilder/catalog_internal_test.go b/coderd/templatebuilder/catalog_internal_test.go new file mode 100644 index 0000000000..6a83b536f8 --- /dev/null +++ b/coderd/templatebuilder/catalog_internal_test.go @@ -0,0 +1,263 @@ +package templatebuilder + +import ( + "testing" + "testing/fstest" + + "github.com/stretchr/testify/require" +) + +func TestParseModulesFromFS(t *testing.T) { + t.Parallel() + + t.Run("ValidManifest", func(t *testing.T) { + t.Parallel() + + fsys := fstest.MapFS{ + "modules/mymod/module.json": &fstest.MapFile{ + Data: []byte(`{ + "id": "mymod", + "display_name": "My Module", + "description": "A test module.", + "icon": "/icons/mymod.svg", + "category": "IDE", + "tags": ["ide"], + "compatible_os": ["linux"], + "conflicts_with": ["other"], + "pinned_version": "1.2.3", + "variables": [ + { + "name": "agent_id", + "type": "string", + "description": "The Coder agent ID.", + "required": true, + "sensitive": false, + "computed": true + }, + { + "name": "port", + "type": "number", + "description": "Port number.", + "default": 8080, + "required": false, + "sensitive": false, + "computed": false + }, + { + "name": "enable_debug", + "type": "bool", + "description": "Enable debug mode.", + "required": false, + "sensitive": false, + "computed": false + }, + { + "name": "api_key", + "type": "string", + "description": "Secret API key.", + "required": true, + "sensitive": true, + "computed": false + } + ] + }`), + }, + } + + modules, err := parseModulesFromFS(fsys) + require.NoError(t, err) + require.Len(t, modules, 1) + + m := modules[0] + require.Equal(t, "mymod", m.ID) + require.Equal(t, "My Module", m.DisplayName) + require.Equal(t, "A test module.", m.Description) + require.Equal(t, "/icons/mymod.svg", m.Icon) + require.Equal(t, "IDE", m.Category) + require.Equal(t, []string{"ide"}, m.Tags) + require.Equal(t, []string{"linux"}, m.CompatibleOS) + require.Equal(t, []string{"other"}, m.ConflictsWith) + require.Equal(t, "1.2.3", m.PinnedVersion) + require.Len(t, m.Variables, 4) + + // Verify variable types parsed correctly. + require.Equal(t, "string", m.Variables[0].Type) + require.Equal(t, "number", m.Variables[1].Type) + require.Equal(t, "bool", m.Variables[2].Type) + + // Verify computed and sensitive fields. + require.True(t, m.Variables[0].Computed) + require.True(t, m.Variables[3].Sensitive) + + // Verify default pointer. + require.Nil(t, m.Variables[0].Default) + require.NotNil(t, m.Variables[1].Default) + require.JSONEq(t, "8080", string(m.Variables[1].Default)) + }) + + t.Run("MultipleModules", func(t *testing.T) { + t.Parallel() + + fsys := fstest.MapFS{ + "modules/alpha/module.json": &fstest.MapFile{ + Data: []byte(`{"id": "alpha", "pinned_version": "1.0.0"}`), + }, + "modules/beta/module.json": &fstest.MapFile{ + Data: []byte(`{"id": "beta", "pinned_version": "2.0.0"}`), + }, + } + + modules, err := parseModulesFromFS(fsys) + require.NoError(t, err) + require.Len(t, modules, 2) + + ids := []string{modules[0].ID, modules[1].ID} + require.Contains(t, ids, "alpha") + require.Contains(t, ids, "beta") + }) + + t.Run("EmptyCatalog", func(t *testing.T) { + t.Parallel() + + fsys := fstest.MapFS{ + "modules/.keep": &fstest.MapFile{Data: []byte{}}, + } + + modules, err := parseModulesFromFS(fsys) + require.NoError(t, err) + require.Empty(t, modules) + }) + + t.Run("RejectsDirWithoutManifest", func(t *testing.T) { + t.Parallel() + + fsys := fstest.MapFS{ + "modules/nomod/readme.txt": &fstest.MapFile{Data: []byte("hi")}, + } + + _, err := parseModulesFromFS(fsys) + require.ErrorContains(t, err, "read nomod/module.json") + }) + + t.Run("RejectsEmptyID", func(t *testing.T) { + t.Parallel() + + fsys := fstest.MapFS{ + "modules/bad/module.json": &fstest.MapFile{ + Data: []byte(`{"id": "", "pinned_version": "1.0.0"}`), + }, + } + + _, err := parseModulesFromFS(fsys) + require.ErrorContains(t, err, "empty id") + }) + + t.Run("RejectsEmptyPinnedVersion", func(t *testing.T) { + t.Parallel() + + fsys := fstest.MapFS{ + "modules/bad/module.json": &fstest.MapFile{ + Data: []byte(`{"id": "bad", "pinned_version": ""}`), + }, + } + + _, err := parseModulesFromFS(fsys) + require.ErrorContains(t, err, "empty pinned_version") + }) + + t.Run("RejectsDuplicateID", func(t *testing.T) { + t.Parallel() + + fsys := fstest.MapFS{ + "modules/a/module.json": &fstest.MapFile{ + Data: []byte(`{"id": "dupe", "pinned_version": "1.0.0"}`), + }, + "modules/b/module.json": &fstest.MapFile{ + Data: []byte(`{"id": "dupe", "pinned_version": "2.0.0"}`), + }, + } + + _, err := parseModulesFromFS(fsys) + require.ErrorContains(t, err, "duplicate module id") + }) + + t.Run("RejectsUnknownVariableType", func(t *testing.T) { + t.Parallel() + + fsys := fstest.MapFS{ + "modules/bad/module.json": &fstest.MapFile{ + Data: []byte(`{ + "id": "bad", + "pinned_version": "1.0.0", + "variables": [{"name": "x", "type": "list"}] + }`), + }, + } + + _, err := parseModulesFromFS(fsys) + require.ErrorContains(t, err, `unknown type "list"`) + }) + + t.Run("RejectsUnknownField", func(t *testing.T) { + t.Parallel() + + fsys := fstest.MapFS{ + "modules/bad/module.json": &fstest.MapFile{ + Data: []byte(`{"id": "bad", "pinned_version": "1.0.0", "dispaly_name": "typo"}`), + }, + } + + _, err := parseModulesFromFS(fsys) + require.ErrorContains(t, err, "decode") + }) + + t.Run("RejectsEmptyVariableName", func(t *testing.T) { + t.Parallel() + + fsys := fstest.MapFS{ + "modules/bad/module.json": &fstest.MapFile{ + Data: []byte(`{ + "id": "bad", + "pinned_version": "1.0.0", + "variables": [{"name": "", "type": "string"}] + }`), + }, + } + + _, err := parseModulesFromFS(fsys) + require.ErrorContains(t, err, "empty name") + }) + + t.Run("RejectsDuplicateVariableName", func(t *testing.T) { + t.Parallel() + + fsys := fstest.MapFS{ + "modules/bad/module.json": &fstest.MapFile{ + Data: []byte(`{ + "id": "bad", + "pinned_version": "1.0.0", + "variables": [ + {"name": "x", "type": "string"}, + {"name": "x", "type": "number"} + ] + }`), + }, + } + + _, err := parseModulesFromFS(fsys) + require.ErrorContains(t, err, "duplicate variable name") + }) + + t.Run("RejectsInvalidJSON", func(t *testing.T) { + t.Parallel() + + fsys := fstest.MapFS{ + "modules/bad/module.json": &fstest.MapFile{ + Data: []byte(`{not json`), + }, + } + + _, err := parseModulesFromFS(fsys) + require.ErrorContains(t, err, "decode") + }) +} diff --git a/coderd/templatebuilder/catalog_test.go b/coderd/templatebuilder/catalog_test.go new file mode 100644 index 0000000000..0fe2cd45c0 --- /dev/null +++ b/coderd/templatebuilder/catalog_test.go @@ -0,0 +1,143 @@ +package templatebuilder_test + +import ( + "encoding/json" + "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, "embedded catalog should contain at least one module") + + // Verify the code-server module is present and valid. + var found bool + for _, m := range modules { + if m.ID == "code-server" { + found = true + require.Equal(t, "code-server", m.DisplayName) + require.Equal(t, "IDE", m.Category) + require.Equal(t, []string{"linux"}, m.CompatibleOS) + require.NotEmpty(t, m.PinnedVersion) + break + } + } + require.True(t, found, "code-server module must be in the embedded catalog") +} + +func TestToSDK(t *testing.T) { + t.Parallel() + + defaultVal := json.RawMessage(`8080`) + manifest := templatebuilder.ModuleManifest{ + ID: "test-mod", + DisplayName: "Test Module", + Description: "A module for testing.", + Icon: "/icons/test.svg", + Category: "Utility", + Tags: []string{"test"}, + CompatibleOS: []string{"linux", "darwin"}, + ConflictsWith: []string{"conflicting-mod"}, + PinnedVersion: "2.5.0", + Variables: []templatebuilder.ModuleVariable{ + { + Name: "agent_id", + Type: "string", + Description: "The Coder agent ID.", + Required: true, + Sensitive: false, + Computed: true, + }, + { + Name: "port", + Type: "number", + Description: "Port to listen on.", + Default: defaultVal, + Required: false, + Sensitive: false, + Computed: false, + }, + { + Name: "secret_key", + Type: "string", + Description: "A sensitive value.", + Required: true, + Sensitive: true, + Computed: false, + }, + }, + } + + sdk := manifest.ToSDK() + + t.Run("TopLevelFields", func(t *testing.T) { + t.Parallel() + + require.Equal(t, "test-mod", sdk.ID) + require.Equal(t, "Test Module", sdk.DisplayName) + require.Equal(t, "A module for testing.", sdk.Description) + require.Equal(t, "/icons/test.svg", sdk.Icon) + require.Equal(t, "Utility", sdk.Category) + require.Equal(t, "2.5.0", sdk.Version, "PinnedVersion should map to Version") + require.Equal(t, []string{"linux", "darwin"}, sdk.CompatibleOS) + require.Equal(t, []string{"conflicting-mod"}, sdk.ConflictsWith) + }) + + t.Run("AllVariableFields", func(t *testing.T) { + t.Parallel() + + require.Len(t, sdk.Variables, 3) + + agent := sdk.Variables[0] + require.Equal(t, "agent_id", agent.Name) + require.Equal(t, codersdk.TemplateBuilderVariableTypeString, agent.Type) + require.Equal(t, "The Coder agent ID.", agent.Description) + require.Nil(t, agent.Default) + require.True(t, agent.Required) + require.False(t, agent.Sensitive) + require.True(t, agent.Computed) + + port := sdk.Variables[1] + require.Equal(t, "port", port.Name) + require.Equal(t, codersdk.TemplateBuilderVariableTypeNumber, port.Type) + require.Equal(t, "Port to listen on.", port.Description) + require.NotNil(t, port.Default) + require.JSONEq(t, "8080", string(port.Default)) + require.False(t, port.Required) + require.False(t, port.Sensitive) + require.False(t, port.Computed) + + secret := sdk.Variables[2] + require.Equal(t, "secret_key", secret.Name) + require.Equal(t, codersdk.TemplateBuilderVariableTypeString, secret.Type) + require.Equal(t, "A sensitive value.", secret.Description) + require.Nil(t, secret.Default) + require.True(t, secret.Required) + require.True(t, secret.Sensitive) + require.False(t, secret.Computed) + }) + + t.Run("NilSlicesNormalizedToEmpty", func(t *testing.T) { + t.Parallel() + + m := templatebuilder.ModuleManifest{ + ID: "nil-slices", + PinnedVersion: "1.0.0", + // CompatibleOS and ConflictsWith are nil. + } + s := m.ToSDK() + require.NotNil(t, s.CompatibleOS, "nil CompatibleOS should become empty slice") + require.NotNil(t, s.ConflictsWith, "nil ConflictsWith should become empty slice") + require.NotNil(t, s.Variables, "nil Variables should become empty slice") + require.Empty(t, s.CompatibleOS) + require.Empty(t, s.ConflictsWith) + require.Empty(t, s.Variables) + }) +} diff --git a/coderd/templatebuilder/modules/code-server/module.json b/coderd/templatebuilder/modules/code-server/module.json new file mode 100644 index 0000000000..674a470526 --- /dev/null +++ b/coderd/templatebuilder/modules/code-server/module.json @@ -0,0 +1,30 @@ +{ + "id": "code-server", + "display_name": "code-server", + "description": "VS Code in the browser", + "icon": "/icon/code.svg", + "category": "IDE", + "tags": ["ide", "web"], + "compatible_os": ["linux"], + "conflicts_with": [], + "pinned_version": "1.2.3", + "variables": [ + { + "name": "agent_id", + "type": "string", + "description": "The ID of the Coder agent. Injected automatically by the builder.", + "required": true, + "sensitive": false, + "computed": true + }, + { + "name": "port", + "type": "number", + "description": "Port to run code-server on", + "default": 13337, + "required": false, + "sensitive": false, + "computed": false + } + ] +} diff --git a/codersdk/templatebuilder.go b/codersdk/templatebuilder.go new file mode 100644 index 0000000000..0d44645f30 --- /dev/null +++ b/codersdk/templatebuilder.go @@ -0,0 +1,42 @@ +package codersdk + +import "encoding/json" + +// TemplateBuilderVariableType enumerates the variable types +// supported by template builder module manifests. +type TemplateBuilderVariableType string + +const ( + TemplateBuilderVariableTypeString TemplateBuilderVariableType = "string" + TemplateBuilderVariableTypeNumber TemplateBuilderVariableType = "number" + TemplateBuilderVariableTypeBool TemplateBuilderVariableType = "bool" +) + +type TemplateBuilderModuleVariable struct { + Name string `json:"name"` + Type TemplateBuilderVariableType `json:"type"` + Description string `json:"description"` + Default json.RawMessage `json:"default,omitempty"` + Required bool `json:"required"` + Sensitive bool `json:"sensitive"` + Computed bool `json:"computed"` +} + +// TemplateBuilderModule is the API response type returned by +// GET /api/v2/templatebuilder/modules. The Version field is +// populated from the catalog manifest'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"` +} + +type TemplateBuilderModulesResponse struct { + Modules []TemplateBuilderModule `json:"modules"` +} diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 7eab4ef02d..ea91175c32 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -8157,6 +8157,49 @@ 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 manifest'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 +export interface TemplateBuilderModuleVariable { + readonly name: string; + readonly type: TemplateBuilderVariableType; + readonly description: string; + readonly default?: Record; + readonly required: boolean; + readonly sensitive: boolean; + readonly computed: boolean; +} + +// From codersdk/templatebuilder.go +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.