feat: import new modules and refactor codegen script (#26838)

This commit is contained in:
Jeremy Ruppel
2026-06-29 17:47:06 -04:00
committed by GitHub
parent e58806cb75
commit 7daf3123cb
12 changed files with 594 additions and 16 deletions
+57 -12
View File
@@ -3,10 +3,13 @@ package main
import (
"context"
"flag"
"fmt"
"log"
"os"
"path/filepath"
"sort"
"strings"
"golang.org/x/xerrors"
)
// ModuleConfig defines the builder catalog metadata that cannot be
@@ -16,6 +19,9 @@ type ModuleConfig struct {
CompatibleOS []string `json:"compatible_os"`
ConflictsWith []string `json:"conflicts_with"`
SkipVars []string `json:"skip_vars,omitempty"`
// Namespace is the registry namespace (e.g. "coder" or "coder-labs").
// When empty, defaults to "coder".
Namespace string `json:"namespace,omitempty"`
}
// moduleConfigs defines the builder-specific metadata for each module.
@@ -29,8 +35,10 @@ var moduleConfigs = map[string]ModuleConfig{
"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{}},
"codex": {Category: "AI Agent", CompatibleOS: []string{"linux"}, ConflictsWith: []string{}, Namespace: "coder-labs"},
"aider": {Category: "AI Agent", CompatibleOS: []string{"linux"}, ConflictsWith: []string{}},
"amazon-q": {Category: "AI Agent", CompatibleOS: []string{"linux"}, ConflictsWith: []string{}},
"antigravity": {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{}},
@@ -38,6 +46,7 @@ var moduleConfigs = map[string]ModuleConfig{
"personalize": {Category: "Utility", CompatibleOS: []string{"linux"}, ConflictsWith: []string{}},
"filebrowser": {Category: "Utility", CompatibleOS: []string{"linux"}, ConflictsWith: []string{}},
"jupyterlab": {Category: "Utility", CompatibleOS: []string{"linux"}, ConflictsWith: []string{}},
"kasmvnc": {Category: "Utility", CompatibleOS: []string{"linux"}, ConflictsWith: []string{}},
}
func main() {
@@ -50,14 +59,24 @@ func main() {
os.Exit(1)
}
args := flag.Args()
if len(args) == 0 {
log.Fatal("specify one or more registry module IDs to generate (e.g. coder/antigravity coder-labs/codex)")
}
refs, err := resolveModuleArgs(args)
if err != nil {
log.Fatal(err)
}
ctx := context.Background()
moduleIDs := sortedKeys(moduleConfigs)
var failures int
for _, id := range moduleIDs {
for _, ref := range refs {
id := ref.slug
cfg := moduleConfigs[id]
registryID := "coder/" + id
log.Printf("Generating %s...", id)
namespace := ref.namespace
registryID := fmt.Sprintf("%s/%s", namespace, id)
log.Printf("Generating %s...", registryID)
regMod, err := fetchModule(ctx, *baseURL, registryID)
if err != nil {
@@ -66,7 +85,7 @@ func main() {
continue
}
version, err := fetchLatestVersion(ctx, *baseURL, "coder", id)
version, err := fetchLatestVersion(ctx, *baseURL, namespace, id)
if err != nil {
log.Printf(" WARNING: could not determine version: %v", err)
version = "0.0.0"
@@ -83,6 +102,7 @@ func main() {
Tags: regMod.Tags,
CompatibleOS: cfg.CompatibleOS,
ConflictsWith: cfg.ConflictsWith,
Namespace: namespace,
PinnedVersion: version,
Variables: vars,
}
@@ -114,11 +134,36 @@ func main() {
}
}
func sortedKeys(m map[string]ModuleConfig) []string {
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
// moduleRef is a resolved module reference with its registry namespace and slug.
type moduleRef struct {
namespace string
slug string
}
// resolveModuleArgs parses CLI arguments in "namespace/slug" format and
// validates each against moduleConfigs. Returns an error if any argument
// is malformed, references an unknown module, or has a namespace that
// does not match the configured value.
func resolveModuleArgs(args []string) ([]moduleRef, error) {
refs := make([]moduleRef, 0, len(args))
for _, arg := range args {
parts := strings.SplitN(arg, "/", 2)
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
return nil, xerrors.Errorf("invalid module ID %q; expected namespace/slug (e.g. coder/antigravity)", arg)
}
namespace, slug := parts[0], parts[1]
cfg, ok := moduleConfigs[slug]
if !ok {
return nil, xerrors.Errorf("unknown module %q; add it to moduleConfigs first", slug)
}
expectedNS := cfg.Namespace
if expectedNS == "" {
expectedNS = "coder"
}
if namespace != expectedNS {
return nil, xerrors.Errorf("module %q namespace mismatch: got %q, moduleConfigs expects %q", slug, namespace, expectedNS)
}
refs = append(refs, moduleRef{namespace: namespace, slug: slug})
}
sort.Strings(keys)
return keys
return refs, nil
}
@@ -0,0 +1,251 @@
package main
import (
"encoding/json"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestResolveModuleArgs(t *testing.T) {
t.Parallel()
tests := []struct {
name string
args []string
want []moduleRef
wantErr string
}{
{
name: "single default namespace",
args: []string{"coder/claude-code"},
want: []moduleRef{{namespace: "coder", slug: "claude-code"}},
},
{
name: "custom namespace",
args: []string{"coder-labs/codex"},
want: []moduleRef{{namespace: "coder-labs", slug: "codex"}},
},
{
name: "multiple modules",
args: []string{"coder/antigravity", "coder-labs/codex", "coder/kasmvnc"},
want: []moduleRef{
{namespace: "coder", slug: "antigravity"},
{namespace: "coder-labs", slug: "codex"},
{namespace: "coder", slug: "kasmvnc"},
},
},
{
name: "missing slash",
args: []string{"noslash"},
wantErr: `invalid module ID "noslash"`,
},
{
name: "empty namespace",
args: []string{"/slug"},
wantErr: `invalid module ID "/slug"`,
},
{
name: "empty slug",
args: []string{"namespace/"},
wantErr: `invalid module ID "namespace/"`,
},
{
name: "unknown module",
args: []string{"coder/nonexistent"},
wantErr: `unknown module "nonexistent"`,
},
{
name: "namespace mismatch",
args: []string{"wrong-ns/codex"},
wantErr: `namespace mismatch: got "wrong-ns", moduleConfigs expects "coder-labs"`,
},
{
name: "error on first bad arg stops",
args: []string{"coder/claude-code", "bad"},
wantErr: `invalid module ID "bad"`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got, err := resolveModuleArgs(tt.args)
if tt.wantErr != "" {
require.Error(t, err)
assert.Contains(t, err.Error(), tt.wantErr)
return
}
require.NoError(t, err)
assert.Equal(t, tt.want, got)
})
}
}
func TestNormalizeIcon(t *testing.T) {
t.Parallel()
tests := []struct {
input string
want string
}{
{"/module/code.svg", "/icon/code.svg"},
{"/module/nested/path.svg", "/icon/nested/path.svg"},
{"/icon/already.svg", "/icon/already.svg"},
{"/other/path.svg", "/other/path.svg"},
{"", ""},
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
t.Parallel()
assert.Equal(t, tt.want, normalizeIcon(tt.input))
})
}
}
func TestLatestVersion(t *testing.T) {
t.Parallel()
entry := func(v string) struct {
Version string `json:"version"`
} {
return struct {
Version string `json:"version"`
}{Version: v}
}
tests := []struct {
name string
entries []struct {
Version string `json:"version"`
}
want string
wantErr string
}{
{
name: "single version",
entries: []struct {
Version string `json:"version"`
}{entry("1.0.0")},
want: "1.0.0",
},
{
name: "picks highest",
entries: []struct {
Version string `json:"version"`
}{entry("1.0.0"), entry("2.1.0"), entry("1.5.3")},
want: "2.1.0",
},
{
name: "handles v prefix",
entries: []struct {
Version string `json:"version"`
}{entry("v1.0.0"), entry("2.0.0")},
want: "2.0.0",
},
{
name: "skips invalid versions",
entries: []struct {
Version string `json:"version"`
}{entry("not-semver"), entry("1.2.3")},
want: "1.2.3",
},
{
name: "empty list",
entries: nil,
wantErr: "no valid semver",
},
{
name: "all invalid",
entries: []struct {
Version string `json:"version"`
}{entry("bad"), entry("also-bad")},
wantErr: "no valid semver",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got, err := latestVersion(tt.entries)
if tt.wantErr != "" {
require.Error(t, err)
assert.Contains(t, err.Error(), tt.wantErr)
return
}
require.NoError(t, err)
assert.Equal(t, tt.want, got)
})
}
}
func TestConvertVariables(t *testing.T) {
t.Parallel()
t.Run("filters skipped and complex types", func(t *testing.T) {
t.Parallel()
vars := []registryVariable{
{Name: "agent_id", Type: "string", Required: true},
{Name: "order", Type: "number"},
{Name: "complex_var", Type: "list(string)"},
{Name: "user_name", Type: "string", Description: "The user name", Default: "default"},
{Name: "count", Type: "number", Required: true},
{Name: "enabled", Type: "bool", Default: true},
{Name: "custom_skip", Type: "string"},
}
result := convertVariables(vars, []string{"custom_skip"})
require.Len(t, result, 4)
// agent_id should be computed and not required
assert.Equal(t, "agent_id", result[0].Name)
assert.True(t, result[0].Computed)
assert.False(t, result[0].Required)
// user_name should have default
assert.Equal(t, "user_name", result[1].Name)
assert.False(t, result[1].Computed)
assert.False(t, result[1].Required)
assert.Equal(t, json.RawMessage(`"default"`), result[1].Default)
// count should be required
assert.Equal(t, "count", result[2].Name)
assert.True(t, result[2].Required)
// enabled should have bool default
assert.Equal(t, "enabled", result[3].Name)
assert.Equal(t, json.RawMessage(`true`), result[3].Default)
})
t.Run("sensitive variable", func(t *testing.T) {
t.Parallel()
vars := []registryVariable{
{Name: "api_key", Type: "string", Sensitive: true},
}
result := convertVariables(vars, nil)
require.Len(t, result, 1)
assert.True(t, result[0].Sensitive)
})
t.Run("empty input", func(t *testing.T) {
t.Parallel()
result := convertVariables(nil, nil)
assert.Nil(t, result)
})
}
func TestModuleConfigsConsistency(t *testing.T) {
t.Parallel()
for slug, cfg := range moduleConfigs {
t.Run(slug, func(t *testing.T) {
t.Parallel()
assert.NotEmpty(t, cfg.Category, "module %q has empty category", slug)
assert.NotEmpty(t, cfg.CompatibleOS, "module %q has empty compatible_os", slug)
assert.NotNil(t, cfg.ConflictsWith, "module %q has nil conflicts_with (use empty slice)", slug)
})
}
}
@@ -12,6 +12,7 @@ type ModuleManifest struct {
Tags []string `json:"tags"`
CompatibleOS []string `json:"compatible_os"`
ConflictsWith []string `json:"conflicts_with"`
Namespace string `json:"namespace"`
PinnedVersion string `json:"pinned_version"`
Variables []ModuleVariable `json:"variables"`
}
+3 -1
View File
@@ -32,7 +32,7 @@ variable "{{ .Name }}" {
{{ end -}}
module "{{ .ID }}" {
count = data.coder_workspace.me.start_count
source = "{{"{{"}} .RegistryBase {{"}}"}}/coder/{{ .ID }}/coder"
source = "{{"{{"}} .RegistryBase {{"}}"}}/{{ .Namespace }}/{{ .ID }}/coder"
version = "{{"{{"}} .PinnedVersion {{"}}"}}"
agent_id = coder_agent.{{"{{"}} .AgentResourceName {{"}}"}}.id
{{- range .NonComputedVars }}
@@ -47,6 +47,7 @@ module "{{ .ID }}" {
type tfTmplData struct {
ID string
Namespace string
SensitiveVars []ModuleVariable
NonComputedVars []ModuleVariable
}
@@ -66,6 +67,7 @@ func writeTFTmpl(path string, m ModuleManifest) error {
data := tfTmplData{
ID: m.ID,
Namespace: m.Namespace,
SensitiveVars: sensitiveVars,
NonComputedVars: nonComputedVars,
}