mirror of
https://github.com/coder/coder.git
synced 2026-09-21 20:51:01 +08:00
feat(scripts): add generator for template builder module catalog (#26193)
> [!NOTE]
> This PR was authored by Coder Agents on behalf of @jeremyruppel.
Adds `scripts/templatebuildermodulegen/`, a Go tool that fetches module metadata from the Coder registry HTTP API and generates the `module.json` manifests and `.tf.tmpl` files used by the template builder catalog.
The generator calls `GET /api/modules/{id}` for per-module metadata (display name, description, icon, tags, variables) and the Terraform protocol versions endpoint for semver resolution. No git clone or HCL parsing required.
Split into four files:
- `main.go`: orchestration, module config map, CLI flags
- `types.go`: output types (`ModuleManifest`, `ModuleVariable`) and API response types
- `fetch.go`: HTTP fetching, version resolution, variable conversion, icon normalization
- `write.go`: JSON writer, `.tf.tmpl` Go template and writer
This commit is contained in:
@@ -0,0 +1,189 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/mod/semver"
|
||||
"golang.org/x/xerrors"
|
||||
)
|
||||
|
||||
const registryBaseURL = "https://registry.coder.com"
|
||||
|
||||
// simpleTypes are the Terraform types the builder UI can represent.
|
||||
var simpleTypes = map[string]bool{
|
||||
"string": true,
|
||||
"number": true,
|
||||
"bool": true,
|
||||
}
|
||||
|
||||
// skipVarNames are variables always excluded from the catalog. These are
|
||||
// UI-ordering or internal plumbing concerns, not admin-facing config.
|
||||
var skipVarNames = map[string]bool{
|
||||
"order": true,
|
||||
"coder_app_order": true,
|
||||
"coder_parameter_order": true,
|
||||
"group": true,
|
||||
"slug": true,
|
||||
"display_name": true,
|
||||
"log_path": true,
|
||||
"install_prefix": true,
|
||||
"share": true,
|
||||
"subdomain": true,
|
||||
}
|
||||
|
||||
// fetchModule retrieves a single module from the registry per-module endpoint.
|
||||
// The id should be "namespace/slug" (e.g. "coder/code-server"); it will be
|
||||
// URL-encoded for the request path.
|
||||
func fetchModule(ctx context.Context, baseURL, id string) (registryModule, error) {
|
||||
reqURL := baseURL + "/api/modules/" + url.PathEscape(id)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil)
|
||||
if err != nil {
|
||||
return registryModule{}, xerrors.Errorf("creating request: %w", err)
|
||||
}
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return registryModule{}, xerrors.Errorf("GET %s: %w", reqURL, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return registryModule{}, xerrors.Errorf("GET %s: status %d", reqURL, resp.StatusCode)
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return registryModule{}, xerrors.Errorf("reading response: %w", err)
|
||||
}
|
||||
|
||||
var mod registryModule
|
||||
if err := json.Unmarshal(body, &mod); err != nil {
|
||||
return registryModule{}, xerrors.Errorf("decoding response: %w", err)
|
||||
}
|
||||
return mod, nil
|
||||
}
|
||||
|
||||
// fetchLatestVersion resolves the latest semver for a module using the
|
||||
// Terraform protocol versions endpoint.
|
||||
func fetchLatestVersion(ctx context.Context, baseURL, namespace, slug string) (string, error) {
|
||||
reqURL := fmt.Sprintf("%s/terraform_protocol/%s/%s/coder/versions", baseURL, namespace, slug)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil)
|
||||
if err != nil {
|
||||
return "", xerrors.Errorf("creating request: %w", err)
|
||||
}
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return "", xerrors.Errorf("GET %s: %w", reqURL, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", xerrors.Errorf("GET %s: status %d", reqURL, resp.StatusCode)
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", xerrors.Errorf("reading response: %w", err)
|
||||
}
|
||||
|
||||
var versionsResp terraformVersionsResponse
|
||||
if err := json.Unmarshal(body, &versionsResp); err != nil {
|
||||
return "", xerrors.Errorf("decoding response: %w", err)
|
||||
}
|
||||
|
||||
if len(versionsResp.Modules) == 0 || len(versionsResp.Modules[0].Versions) == 0 {
|
||||
return "", xerrors.Errorf("no versions found for %s/%s", namespace, slug)
|
||||
}
|
||||
|
||||
return latestVersion(versionsResp.Modules[0].Versions)
|
||||
}
|
||||
|
||||
// latestVersion finds the highest semver from a list of version entries.
|
||||
// The API returns versions without a "v" prefix, so we canonicalize them
|
||||
// for comparison and strip the prefix before returning.
|
||||
func latestVersion(entries []struct {
|
||||
Version string `json:"version"`
|
||||
},
|
||||
) (string, error) {
|
||||
var best string
|
||||
for _, e := range entries {
|
||||
v := e.Version
|
||||
// The semver package requires a "v" prefix, but the registry
|
||||
// API returns bare versions like "1.5.0".
|
||||
if !strings.HasPrefix(v, "v") {
|
||||
v = "v" + v
|
||||
}
|
||||
if !semver.IsValid(v) {
|
||||
continue
|
||||
}
|
||||
if best == "" || semver.Compare(v, best) > 0 {
|
||||
best = v
|
||||
}
|
||||
}
|
||||
if best == "" {
|
||||
return "", xerrors.New("no valid semver tags found")
|
||||
}
|
||||
return strings.TrimPrefix(best, "v"), nil
|
||||
}
|
||||
|
||||
// convertVariables filters and converts registry API variables to the
|
||||
// catalog schema. It skips internal variables, non-simple types, and
|
||||
// marks agent_id as computed.
|
||||
func convertVariables(vars []registryVariable, extraSkip []string) []ModuleVariable {
|
||||
skipSet := make(map[string]bool, len(skipVarNames)+len(extraSkip))
|
||||
for k := range skipVarNames {
|
||||
skipSet[k] = true
|
||||
}
|
||||
for _, s := range extraSkip {
|
||||
skipSet[s] = true
|
||||
}
|
||||
|
||||
var result []ModuleVariable
|
||||
for _, v := range vars {
|
||||
if skipSet[v.Name] {
|
||||
continue
|
||||
}
|
||||
if !simpleTypes[v.Type] {
|
||||
continue
|
||||
}
|
||||
|
||||
computed := v.Name == "agent_id"
|
||||
required := v.Required && !computed
|
||||
|
||||
mv := ModuleVariable{
|
||||
Name: v.Name,
|
||||
Type: v.Type,
|
||||
Description: v.Description,
|
||||
Required: required,
|
||||
Sensitive: v.Sensitive,
|
||||
Computed: computed,
|
||||
}
|
||||
|
||||
if v.Default != nil {
|
||||
raw, err := json.Marshal(v.Default)
|
||||
if err == nil {
|
||||
mv.Default = raw
|
||||
}
|
||||
}
|
||||
|
||||
result = append(result, mv)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// normalizeIcon converts registry icon paths to web-servable paths.
|
||||
// The API returns paths like "/module/code.svg"; we serve them as
|
||||
// "/icon/code.svg" in the Coder dashboard.
|
||||
func normalizeIcon(icon string) string {
|
||||
if strings.HasPrefix(icon, "/module/") {
|
||||
return "/icon/" + strings.TrimPrefix(icon, "/module/")
|
||||
}
|
||||
return icon
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
)
|
||||
|
||||
// ModuleConfig defines the builder catalog metadata that cannot be
|
||||
// inferred from the registry (category, OS compatibility, conflicts).
|
||||
type ModuleConfig struct {
|
||||
Category string `json:"category"`
|
||||
CompatibleOS []string `json:"compatible_os"`
|
||||
ConflictsWith []string `json:"conflicts_with"`
|
||||
SkipVars []string `json:"skip_vars,omitempty"`
|
||||
}
|
||||
|
||||
// moduleConfigs defines the builder-specific metadata for each module.
|
||||
var moduleConfigs = map[string]ModuleConfig{
|
||||
"code-server": {Category: "IDE", CompatibleOS: []string{"linux"}, ConflictsWith: []string{"vscode-web"}},
|
||||
"jetbrains": {Category: "IDE", CompatibleOS: []string{"linux"}, ConflictsWith: []string{}},
|
||||
"vscode-desktop": {Category: "IDE", CompatibleOS: []string{"linux"}, ConflictsWith: []string{}},
|
||||
"vscode-web": {Category: "IDE", CompatibleOS: []string{"linux"}, ConflictsWith: []string{"code-server"}},
|
||||
"cursor": {Category: "IDE", CompatibleOS: []string{"linux"}, ConflictsWith: []string{}},
|
||||
"windsurf": {Category: "IDE", CompatibleOS: []string{"linux"}, ConflictsWith: []string{}},
|
||||
"zed": {Category: "IDE", CompatibleOS: []string{"linux"}, ConflictsWith: []string{}},
|
||||
"kiro": {Category: "IDE", CompatibleOS: []string{"linux"}, ConflictsWith: []string{}},
|
||||
"claude-code": {Category: "AI Agent", CompatibleOS: []string{"linux"}, ConflictsWith: []string{}},
|
||||
"aider": {Category: "AI Agent", CompatibleOS: []string{"linux"}, ConflictsWith: []string{}},
|
||||
"goose": {Category: "AI Agent", CompatibleOS: []string{"linux"}, ConflictsWith: []string{}},
|
||||
"amazon-q": {Category: "AI Agent", CompatibleOS: []string{"linux"}, ConflictsWith: []string{}},
|
||||
"git-clone": {Category: "Source Control", CompatibleOS: []string{"linux"}, ConflictsWith: []string{}},
|
||||
"git-config": {Category: "Source Control", CompatibleOS: []string{"linux"}, ConflictsWith: []string{}},
|
||||
"git-commit-signing": {Category: "Source Control", CompatibleOS: []string{"linux"}, ConflictsWith: []string{}},
|
||||
"dotfiles": {Category: "Utility", CompatibleOS: []string{"linux"}, ConflictsWith: []string{}},
|
||||
"personalize": {Category: "Utility", CompatibleOS: []string{"linux"}, ConflictsWith: []string{}},
|
||||
"filebrowser": {Category: "Utility", CompatibleOS: []string{"linux"}, ConflictsWith: []string{}},
|
||||
"jupyterlab": {Category: "Utility", CompatibleOS: []string{"linux"}, ConflictsWith: []string{}},
|
||||
}
|
||||
|
||||
func main() {
|
||||
outputPath := flag.String("output", "", "Output directory for generated module files (required)")
|
||||
baseURL := flag.String("registry-url", registryBaseURL, "Base URL of the Coder registry API")
|
||||
flag.Parse()
|
||||
|
||||
if *outputPath == "" {
|
||||
flag.Usage()
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
moduleIDs := sortedKeys(moduleConfigs)
|
||||
var failures int
|
||||
|
||||
for _, id := range moduleIDs {
|
||||
cfg := moduleConfigs[id]
|
||||
registryID := "coder/" + id
|
||||
log.Printf("Generating %s...", id)
|
||||
|
||||
regMod, err := fetchModule(ctx, *baseURL, registryID)
|
||||
if err != nil {
|
||||
log.Printf(" ERROR fetching module: %v", err)
|
||||
failures++
|
||||
continue
|
||||
}
|
||||
|
||||
version, err := fetchLatestVersion(ctx, *baseURL, "coder", id)
|
||||
if err != nil {
|
||||
log.Printf(" WARNING: could not determine version: %v", err)
|
||||
version = "0.0.0"
|
||||
}
|
||||
|
||||
vars := convertVariables(regMod.Variables, cfg.SkipVars)
|
||||
|
||||
manifest := ModuleManifest{
|
||||
ID: id,
|
||||
DisplayName: regMod.DisplayName,
|
||||
Description: regMod.Description,
|
||||
Icon: normalizeIcon(regMod.IconURL),
|
||||
Category: cfg.Category,
|
||||
Tags: regMod.Tags,
|
||||
CompatibleOS: cfg.CompatibleOS,
|
||||
ConflictsWith: cfg.ConflictsWith,
|
||||
PinnedVersion: version,
|
||||
Variables: vars,
|
||||
}
|
||||
|
||||
outDir := filepath.Join(*outputPath, id)
|
||||
if err := os.MkdirAll(outDir, 0o755); err != nil {
|
||||
log.Printf(" ERROR creating directory: %v", err)
|
||||
failures++
|
||||
continue
|
||||
}
|
||||
|
||||
if err := writeModuleJSON(filepath.Join(outDir, "module.json"), manifest); err != nil {
|
||||
log.Printf(" ERROR writing module.json: %v", err)
|
||||
failures++
|
||||
continue
|
||||
}
|
||||
|
||||
if err := writeTFTmpl(filepath.Join(outDir, id+".tf.tmpl"), manifest); err != nil {
|
||||
log.Printf(" ERROR writing .tf.tmpl: %v", err)
|
||||
failures++
|
||||
continue
|
||||
}
|
||||
|
||||
log.Printf(" OK: %d variables, version %s", len(vars), version)
|
||||
}
|
||||
|
||||
if failures > 0 {
|
||||
log.Fatalf("Failed to generate %d module(s)", failures)
|
||||
}
|
||||
}
|
||||
|
||||
func sortedKeys(m map[string]ModuleConfig) []string {
|
||||
keys := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
return keys
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package main
|
||||
|
||||
import "encoding/json"
|
||||
|
||||
// ModuleManifest is the on-disk module.json schema.
|
||||
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 is 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"`
|
||||
}
|
||||
|
||||
// registryModule is the JSON shape returned by GET /api/modules/{id}.
|
||||
type registryModule struct {
|
||||
ID string `json:"id"`
|
||||
Slug string `json:"slug"`
|
||||
DisplayName string `json:"displayName"`
|
||||
Description string `json:"description"`
|
||||
IconURL string `json:"iconUrl"`
|
||||
Tags []string `json:"tags"`
|
||||
Variables []registryVariable `json:"variables"`
|
||||
Namespace string `json:"contributorNamespace"`
|
||||
}
|
||||
|
||||
// registryVariable is a variable as returned by the registry API.
|
||||
// The Default field is a raw JSON value because the API returns typed
|
||||
// defaults (string, bool, number, null, array, object).
|
||||
type registryVariable struct {
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
Description string `json:"description"`
|
||||
Default interface{} `json:"default"`
|
||||
Required bool `json:"required"`
|
||||
Sensitive bool `json:"sensitive"`
|
||||
}
|
||||
|
||||
// terraformVersionsResponse wraps the Terraform protocol versions endpoint.
|
||||
type terraformVersionsResponse struct {
|
||||
Modules []struct {
|
||||
Versions []struct {
|
||||
Version string `json:"version"`
|
||||
} `json:"versions"`
|
||||
} `json:"modules"`
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"text/template"
|
||||
)
|
||||
|
||||
func writeModuleJSON(path string, m ModuleManifest) error {
|
||||
data, err := json.MarshalIndent(m, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data = append(data, '\n')
|
||||
return os.WriteFile(path, data, 0o600)
|
||||
}
|
||||
|
||||
var tfTmplTemplate = template.Must(template.New("tf.tmpl").Funcs(template.FuncMap{
|
||||
"hclValue": func(v ModuleVariable) string {
|
||||
if v.Sensitive {
|
||||
return "var." + v.Name
|
||||
}
|
||||
return "{{ .Variables." + v.Name + " }}"
|
||||
},
|
||||
}).Parse(`{{- range .SensitiveVars }}
|
||||
variable "{{ .Name }}" {
|
||||
description = "{{ .Description }}"
|
||||
type = {{ .Type }}
|
||||
sensitive = true
|
||||
}
|
||||
{{ end -}}
|
||||
module "{{ .ID }}" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "{{"{{"}} .RegistryBase {{"}}"}}/coder/{{ .ID }}/coder"
|
||||
version = "{{"{{"}} .PinnedVersion {{"}}"}}"
|
||||
agent_id = coder_agent.{{"{{"}} .AgentResourceName {{"}}"}}.id
|
||||
{{- range .NonComputedVars }}
|
||||
{{- if .Sensitive }}
|
||||
{{ .Name }} = var.{{ .Name }}
|
||||
{{- else }}
|
||||
{{ .Name }} = {{"{{"}} .Variables.{{ .Name }} {{"}}"}}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
}
|
||||
`))
|
||||
|
||||
type tfTmplData struct {
|
||||
ID string
|
||||
SensitiveVars []ModuleVariable
|
||||
NonComputedVars []ModuleVariable
|
||||
}
|
||||
|
||||
func writeTFTmpl(path string, m ModuleManifest) error {
|
||||
var sensitiveVars []ModuleVariable
|
||||
var nonComputedVars []ModuleVariable
|
||||
for _, v := range m.Variables {
|
||||
if v.Computed {
|
||||
continue
|
||||
}
|
||||
nonComputedVars = append(nonComputedVars, v)
|
||||
if v.Sensitive {
|
||||
sensitiveVars = append(sensitiveVars, v)
|
||||
}
|
||||
}
|
||||
|
||||
data := tfTmplData{
|
||||
ID: m.ID,
|
||||
SensitiveVars: sensitiveVars,
|
||||
NonComputedVars: nonComputedVars,
|
||||
}
|
||||
|
||||
f, err := os.Create(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = tfTmplTemplate.Execute(f, data)
|
||||
if closeErr := f.Close(); err == nil {
|
||||
err = closeErr
|
||||
}
|
||||
return err
|
||||
}
|
||||
Reference in New Issue
Block a user