mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +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
|
||||
}
|
||||
Reference in New Issue
Block a user