Files
coder/scripts/templatebuildermodulegen/fetch.go
T
Jeremy Ruppel 809bd613e3 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
2026-06-15 09:25:56 -04:00

190 lines
5.1 KiB
Go

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
}