Files
coder/coderd/templatebuilder/catalog.go
T

215 lines
6.6 KiB
Go

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"`
Namespace string `json:"namespace"`
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
}
// CompatibleWithOS reports whether the module is compatible with the given OS.
// Modules with an empty CompatibleOS list are compatible with all platforms.
func (m ModuleManifest) CompatibleWithOS(os string) bool {
if len(m.CompatibleOS) == 0 {
return true
}
for _, supported := range m.CompatibleOS {
if supported == os {
return true
}
}
return false
}
// ToSDK converts a ModuleManifest to the API response type.
// PinnedVersion is mapped to Version; tags are not part of the API surface.
// Computed variables are excluded from the output.
func (m ModuleManifest) ToSDK() codersdk.TemplateBuilderModule {
variables := make([]codersdk.TemplateBuilderModuleVariable, 0, len(m.Variables))
for _, v := range m.Variables {
// Computed variables (e.g. agent_id) are wired by the builder
// automatically and must not be surfaced to the user.
if v.Computed {
continue
}
variables = append(variables, codersdk.TemplateBuilderModuleVariable{
Name: v.Name,
Type: validVariableTypes[v.Type],
Description: v.Description,
Default: v.Default,
Required: v.Required,
Sensitive: v.Sensitive,
})
}
// 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,
}
}
// ModuleTemplateFS returns an fs.FS rooted at the embedded directory for
// the given module ID, providing access to its .tf.tmpl file.
func ModuleTemplateFS(moduleID string) (fs.FS, error) {
modPath := modulesDir + "/" + moduleID
// Verify the directory exists. fs.Sub on embed.FS silently succeeds
// for nonexistent paths, so we check for the expected .tf.tmpl file.
tmplName := moduleID + ".tf.tmpl"
if _, err := fs.Stat(modulesFS, modPath+"/"+tmplName); err != nil {
return nil, xerrors.Errorf("module %q not found in embedded catalog: %w", moduleID, err)
}
sub, err := fs.Sub(modulesFS, modPath)
if err != nil {
return nil, xerrors.Errorf("module %q sub-filesystem: %w", moduleID, err)
}
return sub, nil
}