diff --git a/scripts/templatebuildermodulegen/fetch.go b/scripts/templatebuildermodulegen/fetch.go new file mode 100644 index 0000000000..27021de885 --- /dev/null +++ b/scripts/templatebuildermodulegen/fetch.go @@ -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 +} diff --git a/scripts/templatebuildermodulegen/main.go b/scripts/templatebuildermodulegen/main.go new file mode 100644 index 0000000000..016446a9b2 --- /dev/null +++ b/scripts/templatebuildermodulegen/main.go @@ -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 +} diff --git a/scripts/templatebuildermodulegen/types.go b/scripts/templatebuildermodulegen/types.go new file mode 100644 index 0000000000..702409291b --- /dev/null +++ b/scripts/templatebuildermodulegen/types.go @@ -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"` +} diff --git a/scripts/templatebuildermodulegen/write.go b/scripts/templatebuildermodulegen/write.go new file mode 100644 index 0000000000..ca0704629c --- /dev/null +++ b/scripts/templatebuildermodulegen/write.go @@ -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 +}