mirror of
https://github.com/coder/coder.git
synced 2026-06-02 20:48:20 +00:00
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:
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
Generated
+50
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user