mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
feat: import new modules and refactor codegen script (#26838)
This commit is contained in:
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user