mirror of
https://github.com/coder/coder.git
synced 2026-09-22 05:05:20 +08:00
> [!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
190 lines
5.1 KiB
Go
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
|
|
}
|