From ed975968a1351f4786a1559259a866906220508b Mon Sep 17 00:00:00 2001 From: Qi Zhang Date: Sat, 28 Feb 2026 11:36:00 +0800 Subject: [PATCH] feat: Improve multi-language scenario support, optimize CNB version detection and startup parameter configuration Signed-off-by: Qi Zhang --- builder/parser/code/cnb_versions.go | 13 +-- builder/parser/code/cnb_versions_test.go | 91 +++++++++++++++++++ builder/parser/source_code.go | 14 ++- builder/parser/source_code_args_test.go | 106 +++++++++++++++++++++++ 4 files changed, 215 insertions(+), 9 deletions(-) create mode 100644 builder/parser/code/cnb_versions_test.go create mode 100644 builder/parser/source_code_args_test.go diff --git a/builder/parser/code/cnb_versions.go b/builder/parser/code/cnb_versions.go index 20f0c22df..47c2062d6 100644 --- a/builder/parser/code/cnb_versions.go +++ b/builder/parser/code/cnb_versions.go @@ -24,13 +24,16 @@ var cnbNodeVersions = []CNBVersion{ } // GetCNBVersions returns the supported CNB versions for a given language. +// Supports composite languages like "dockerfile,Node.js" by checking each part. func GetCNBVersions(lang string) []CNBVersion { - switch strings.ToLower(lang) { - case "nodejs", "node", "node.js": - return cnbNodeVersions - default: - return []CNBVersion{} + lower := strings.ToLower(lang) + for _, part := range strings.Split(lower, ",") { + switch strings.TrimSpace(part) { + case "nodejs", "node", "node.js": + return cnbNodeVersions + } } + return []CNBVersion{} } // MatchCNBVersion resolves a fuzzy version spec (e.g. "20.x", ">=20.0", "20") diff --git a/builder/parser/code/cnb_versions_test.go b/builder/parser/code/cnb_versions_test.go new file mode 100644 index 000000000..04b2d7865 --- /dev/null +++ b/builder/parser/code/cnb_versions_test.go @@ -0,0 +1,91 @@ +package code + +import "testing" + +func TestGetCNBVersions(t *testing.T) { + tests := []struct { + name string + lang string + wantCount int // 0 means empty + }{ + // Single languages + {"nodejs returns versions", "nodejs", len(cnbNodeVersions)}, + {"Node.js returns versions", "Node.js", len(cnbNodeVersions)}, + {"node returns versions", "node", len(cnbNodeVersions)}, + {"NODEJS returns versions (case-insensitive)", "NODEJS", len(cnbNodeVersions)}, + {"python returns empty", "python", 0}, + {"dockerfile returns empty", "dockerfile", 0}, + {"empty string returns empty", "", 0}, + // Composite languages (comma-separated) + {"dockerfile,Node.js returns versions", "dockerfile,Node.js", len(cnbNodeVersions)}, + {"Node.js,dockerfile returns versions", "Node.js,dockerfile", len(cnbNodeVersions)}, + {"dockerfile,nodejs returns versions", "dockerfile,nodejs", len(cnbNodeVersions)}, + {"dockerfile,static returns empty", "dockerfile,static", 0}, + {"Dockerfile,Node.js (mixed case)", "Dockerfile,Node.js", len(cnbNodeVersions)}, + // Whitespace around parts + {"dockerfile, Node.js (space after comma)", "dockerfile, Node.js", len(cnbNodeVersions)}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := GetCNBVersions(tt.lang) + if len(got) != tt.wantCount { + t.Errorf("GetCNBVersions(%q) returned %d versions, want %d", tt.lang, len(got), tt.wantCount) + } + }) + } +} + +func TestMatchCNBVersion_CompositeLanguage(t *testing.T) { + tests := []struct { + name string + lang string + versionSpec string + want string + }{ + // Composite language should resolve version correctly + {"dockerfile,Node.js with major 20", "dockerfile,Node.js", "20", "20.20.0"}, + {"dockerfile,Node.js with major 22", "dockerfile,Node.js", "22", "22.22.0"}, + {"dockerfile,Node.js with exact", "dockerfile,Node.js", "20.19.6", "20.19.6"}, + {"dockerfile,Node.js empty spec returns default", "dockerfile,Node.js", "", "24.13.0"}, + // Single language still works + {"nodejs with major 20", "nodejs", "20", "20.20.0"}, + {"Node.js with fuzzy 20.x", "Node.js", "20.x", "20.20.0"}, + {"Node.js with >=22", "Node.js", ">=22", "22.22.0"}, + // Unsupported language returns empty + {"python returns empty", "python", "3.11", ""}, + {"dockerfile alone returns empty", "dockerfile", "20", ""}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := MatchCNBVersion(tt.lang, tt.versionSpec) + if got != tt.want { + t.Errorf("MatchCNBVersion(%q, %q) = %q, want %q", tt.lang, tt.versionSpec, got, tt.want) + } + }) + } +} + +func TestExtractMajorFromSpec(t *testing.T) { + tests := []struct { + spec string + want int + }{ + {"20", 20}, + {"20.x", 20}, + {">=20.0", 20}, + {"^22.0.0", 22}, + {"~18.10", 18}, + {"v24", 24}, + {"=20.0.0", 20}, + {"", 0}, + {"abc", 0}, + } + for _, tt := range tests { + t.Run(tt.spec, func(t *testing.T) { + got := extractMajorFromSpec(tt.spec) + if got != tt.want { + t.Errorf("extractMajorFromSpec(%q) = %d, want %d", tt.spec, got, tt.want) + } + }) + } +} diff --git a/builder/parser/source_code.go b/builder/parser/source_code.go index 9d2c8f59d..a2800b054 100644 --- a/builder/parser/source_code.go +++ b/builder/parser/source_code.go @@ -534,13 +534,16 @@ func (d *SourceCodeParse) Parse() ParseErrorList { // - 纯静态语言:始终使用 nginx,端口 8080 // - Node.js 前端框架(static 类型):构建后由 nginx 托管,端口 8080 // - Node.js 后端框架(dynamic 类型):默认端口 3000(Next.js/Nuxt/Remix/Nest.js/Express) - if d.Lang == code.Static || - (d.Lang == code.Nodejs && runtimeInfo != nil && runtimeInfo["RUNTIME_TYPE"] == "static") { + // 多语言场景(如 "dockerfile,Node.js")也需要设置 CNB 默认端口 + langStr := string(d.Lang) + hasNodejs := strings.Contains(langStr, string(code.Nodejs)) + hasStatic := strings.Contains(langStr, string(code.Static)) + if hasStatic || (hasNodejs && runtimeInfo != nil && runtimeInfo["RUNTIME_TYPE"] == "static") { if _, ok := d.ports[8080]; !ok { d.ports[8080] = &types.Port{ContainerPort: 8080, Protocol: "http"} } } - if d.Lang == code.Nodejs && runtimeInfo != nil && runtimeInfo["RUNTIME_TYPE"] == "dynamic" { + if hasNodejs && runtimeInfo != nil && runtimeInfo["RUNTIME_TYPE"] == "dynamic" { if _, ok := d.ports[3000]; !ok { d.ports[3000] = &types.Port{ContainerPort: 3000, Protocol: "http"} } @@ -635,8 +638,11 @@ func (d *SourceCodeParse) GetImage() Image { } // GetArgs 启动参数 +// Nodejs/Static 走 CNB 构建,镜像自带 entrypoint,不需要 slug runner 的 ["start", "web"] 参数。 +// 多语言场景(如 "dockerfile,Node.js")同样适用。 func (d *SourceCodeParse) GetArgs() []string { - if d.Lang == code.Nodejs || d.Lang == code.Static { + lang := string(d.Lang) + if strings.Contains(lang, string(code.Nodejs)) || strings.Contains(lang, string(code.Static)) { return nil } return d.args diff --git a/builder/parser/source_code_args_test.go b/builder/parser/source_code_args_test.go new file mode 100644 index 000000000..bf2db7a24 --- /dev/null +++ b/builder/parser/source_code_args_test.go @@ -0,0 +1,106 @@ +package parser + +import ( + "strings" + "testing" + + "github.com/goodrain/rainbond/builder/parser/code" + "github.com/goodrain/rainbond/builder/parser/types" +) + +func TestGetArgs_MultiLanguage(t *testing.T) { + tests := []struct { + name string + lang code.Lang + wantNil bool + }{ + // 单语言 + {"pure Node.js returns nil", code.Nodejs, true}, + {"pure static returns nil", code.Static, true}, + {"pure Python returns args", code.Python, false}, + {"pure dockerfile returns args", code.Dockerfile, false}, + {"Java-maven returns args", code.JavaMaven, false}, + // 多语言(逗号分隔) + {"dockerfile,Node.js returns nil", code.Lang("dockerfile,Node.js"), true}, + {"dockerfile,static returns nil", code.Lang("dockerfile,static"), true}, + {"Node.js,dockerfile returns nil", code.Lang("Node.js,dockerfile"), true}, + // 不应误匹配的语言(大小写敏感) + {"NodeJSStatic not matched (no regression)", code.NodeJSStatic, false}, + {"NodeJSDockerfile not matched", code.NodeJSDockerfile, false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + d := &SourceCodeParse{ + Lang: tt.lang, + args: []string{"start", "web"}, + } + got := d.GetArgs() + if tt.wantNil && got != nil { + t.Errorf("GetArgs() = %v; want nil", got) + } + if !tt.wantNil && got == nil { + t.Errorf("GetArgs() = nil; want non-nil") + } + }) + } +} + +func TestCNBDefaultPorts_MultiLanguage(t *testing.T) { + tests := []struct { + name string + lang code.Lang + runtimeType string // "" means no runtimeInfo + wantPort int // 0 means no CNB port expected + }{ + // 纯语言 + {"static gets 8080", code.Static, "", 8080}, + {"Node.js static gets 8080", code.Nodejs, "static", 8080}, + {"Node.js dynamic gets 3000", code.Nodejs, "dynamic", 3000}, + {"dockerfile gets no port", code.Dockerfile, "", 0}, + // 多语言 + {"dockerfile,Node.js static gets 8080", code.Lang("dockerfile,Node.js"), "static", 8080}, + {"dockerfile,Node.js dynamic gets 3000", code.Lang("dockerfile,Node.js"), "dynamic", 3000}, + {"dockerfile,static gets 8080", code.Lang("dockerfile,static"), "", 8080}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + d := &SourceCodeParse{ + Lang: tt.lang, + ports: make(map[int]*types.Port), + } + applyCNBDefaultPorts(d, tt.runtimeType) + + if tt.wantPort == 0 { + if len(d.ports) != 0 { + t.Errorf("expected no ports, got %v", d.ports) + } + } else { + if _, ok := d.ports[tt.wantPort]; !ok { + t.Errorf("expected port %d, got %v", tt.wantPort, d.ports) + } + } + }) + } +} + +// applyCNBDefaultPorts mirrors the port logic in source_code.go Parse() for testability. +func applyCNBDefaultPorts(d *SourceCodeParse, runtimeType string) { + var runtimeInfo map[string]string + if runtimeType != "" { + runtimeInfo = map[string]string{"RUNTIME_TYPE": runtimeType} + } + + langStr := string(d.Lang) + hasNodejs := strings.Contains(langStr, string(code.Nodejs)) + hasStatic := strings.Contains(langStr, string(code.Static)) + if hasStatic || (hasNodejs && runtimeInfo != nil && runtimeInfo["RUNTIME_TYPE"] == "static") { + if _, ok := d.ports[8080]; !ok { + d.ports[8080] = &types.Port{ContainerPort: 8080, Protocol: "http"} + } + } + if hasNodejs && runtimeInfo != nil && runtimeInfo["RUNTIME_TYPE"] == "dynamic" { + if _, ok := d.ports[3000]; !ok { + d.ports[3000] = &types.Port{ContainerPort: 3000, Protocol: "http"} + } + } +}