mirror of
https://github.com/coder/coder.git
synced 2026-09-21 20:51:01 +08:00
215 lines
6.6 KiB
Go
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
|
|
}
|