package cnb import ( "context" "fmt" "io" "os" "path/filepath" "strings" "testing" "github.com/eapache/channels" "github.com/goodrain/rainbond/builder" "github.com/goodrain/rainbond/builder/build" "github.com/goodrain/rainbond/builder/parser/code" "github.com/goodrain/rainbond/event" corev1 "k8s.io/api/core/v1" "k8s.io/client-go/kubernetes" ) // --- helpers --- func newNodeDir(t *testing.T) string { t.Helper() dir := t.TempDir() os.WriteFile(filepath.Join(dir, "package.json"), []byte(`{"name":"test"}`), 0644) return dir } // --- config.go --- // capability_id: rainbond.cnb.builder-image func TestGetCNBBuilderImage(t *testing.T) { t.Run("default", func(t *testing.T) { os.Unsetenv("CNB_BUILDER_IMAGE") if got := GetCNBBuilderImage(); got != DefaultCNBBuilder { t.Errorf("got %q; want %q", got, DefaultCNBBuilder) } }) t.Run("custom", func(t *testing.T) { os.Setenv("CNB_BUILDER_IMAGE", "custom:latest") defer os.Unsetenv("CNB_BUILDER_IMAGE") if got := GetCNBBuilderImage(); got != "custom:latest" { t.Errorf("got %q; want %q", got, "custom:latest") } }) } // capability_id: rainbond.cnb.run-image func TestGetCNBRunImage(t *testing.T) { os.Unsetenv("CNB_RUN_IMAGE") if got := GetCNBRunImage(); got != DefaultCNBRunImage { t.Errorf("got %q; want %q", got, DefaultCNBRunImage) } } // --- order.go --- // capability_id: rainbond.cnb.order-toml func TestWriteCustomOrder(t *testing.T) { b := &Builder{} t.Run("writes order.toml with version", func(t *testing.T) { dir := t.TempDir() re := &build.Request{SourceDir: dir} bps := []orderBuildpack{{ID: "paketo-buildpacks/nginx", Version: "1.0.12"}} flag := b.writeCustomOrder(re, bps, "test") if flag != "-order=/workspace/.cnb-order.toml" { t.Errorf("got flag %q", flag) } content, _ := os.ReadFile(filepath.Join(dir, ".cnb-order.toml")) if !strings.Contains(string(content), `id = "paketo-buildpacks/nginx"`) { t.Error("expected nginx buildpack ID") } if !strings.Contains(string(content), `version = "1.0.12"`) { t.Error("expected version") } }) t.Run("optional buildpack", func(t *testing.T) { dir := t.TempDir() re := &build.Request{SourceDir: dir} bps := []orderBuildpack{{ID: "bp/test", Optional: true}} b.writeCustomOrder(re, bps, "test") content, _ := os.ReadFile(filepath.Join(dir, ".cnb-order.toml")) if !strings.Contains(string(content), "optional = true") { t.Error("expected optional = true") } }) t.Run("no version omits version line", func(t *testing.T) { dir := t.TempDir() re := &build.Request{SourceDir: dir} bps := []orderBuildpack{{ID: "bp/test"}} b.writeCustomOrder(re, bps, "test") content, _ := os.ReadFile(filepath.Join(dir, ".cnb-order.toml")) if strings.Contains(string(content), "version") { t.Error("expected no version line") } }) } // capability_id: rainbond.cnb.static-buildpacks func TestStaticBuildpacks(t *testing.T) { s := &staticConfig{} dir := t.TempDir() // no package.json = pure static re := &build.Request{SourceDir: dir, BuildEnvs: map[string]string{}} bps := s.CustomOrder(re) if len(bps) != 1 || bps[0].ID != "paketo-buildpacks/nginx" { t.Errorf("expected single nginx buildpack, got %+v", bps) } } // --- mirror.go --- // capability_id: rainbond.cnb.mirror-config func TestInjectMirrorConfig(t *testing.T) { n := &nodejsConfig{} t.Run("skip for pure static", func(t *testing.T) { dir := t.TempDir() re := &build.Request{SourceDir: dir, BuildEnvs: map[string]string{}} n.InjectMirrorConfig(re) if _, err := os.Stat(filepath.Join(dir, ".npmrc")); !os.IsNotExist(err) { t.Error("expected no .npmrc for pure static") } }) t.Run("project source with existing config", func(t *testing.T) { dir := newNodeDir(t) os.WriteFile(filepath.Join(dir, ".npmrc"), []byte("custom"), 0644) re := &build.Request{SourceDir: dir, BuildEnvs: map[string]string{"CNB_MIRROR_SOURCE": "project"}} n.InjectMirrorConfig(re) content, _ := os.ReadFile(filepath.Join(dir, ".npmrc")) if string(content) != "custom" { t.Error("project .npmrc should be preserved") } }) t.Run("project source without config falls through", func(t *testing.T) { dir := newNodeDir(t) re := &build.Request{SourceDir: dir, BuildEnvs: map[string]string{"CNB_MIRROR_SOURCE": "project"}} n.InjectMirrorConfig(re) if _, err := os.Stat(filepath.Join(dir, ".npmrc")); os.IsNotExist(err) { t.Error("expected .npmrc fallback") } }) t.Run("creates both npmrc and yarnrc", func(t *testing.T) { dir := newNodeDir(t) re := &build.Request{SourceDir: dir, BuildEnvs: map[string]string{}} n.InjectMirrorConfig(re) for _, f := range []string{".npmrc", ".yarnrc"} { if _, err := os.Stat(filepath.Join(dir, f)); os.IsNotExist(err) { t.Errorf("expected %s to be created", f) } } }) } // capability_id: rainbond.cnb.config-file func TestInjectConfigFile(t *testing.T) { t.Run("existing file not overwritten", func(t *testing.T) { dir := t.TempDir() os.WriteFile(filepath.Join(dir, ".npmrc"), []byte("existing"), 0644) re := &build.Request{SourceDir: dir, BuildEnvs: map[string]string{}} injectConfigFile(re, ".npmrc", "CNB_MIRROR_NPMRC") content, _ := os.ReadFile(filepath.Join(dir, ".npmrc")) if string(content) != "existing" { t.Error("should not overwrite") } }) t.Run("user provided content", func(t *testing.T) { dir := t.TempDir() re := &build.Request{SourceDir: dir, BuildEnvs: map[string]string{"CNB_MIRROR_NPMRC": "custom"}} injectConfigFile(re, ".npmrc", "CNB_MIRROR_NPMRC") content, _ := os.ReadFile(filepath.Join(dir, ".npmrc")) if string(content) != "custom" { t.Errorf("got %q; want custom", string(content)) } }) t.Run("china mirror disabled", func(t *testing.T) { dir := t.TempDir() os.Setenv("ENABLE_CHINA_MIRROR", "false") defer os.Unsetenv("ENABLE_CHINA_MIRROR") re := &build.Request{SourceDir: dir, BuildEnvs: map[string]string{}} injectConfigFile(re, ".npmrc", "CNB_MIRROR_NPMRC") if _, err := os.Stat(filepath.Join(dir, ".npmrc")); !os.IsNotExist(err) { t.Error("expected no .npmrc when mirror disabled") } }) t.Run("default npmrc", func(t *testing.T) { dir := t.TempDir() os.Setenv("ENABLE_CHINA_MIRROR", "true") defer os.Unsetenv("ENABLE_CHINA_MIRROR") re := &build.Request{SourceDir: dir, BuildEnvs: map[string]string{}} injectConfigFile(re, ".npmrc", "CNB_MIRROR_NPMRC") content, _ := os.ReadFile(filepath.Join(dir, ".npmrc")) if string(content) != DefaultNpmrcContent { t.Errorf("got %q; want default", string(content)) } }) t.Run("default yarnrc", func(t *testing.T) { dir := t.TempDir() os.Setenv("ENABLE_CHINA_MIRROR", "true") defer os.Unsetenv("ENABLE_CHINA_MIRROR") re := &build.Request{SourceDir: dir, BuildEnvs: map[string]string{}} injectConfigFile(re, ".yarnrc", "CNB_MIRROR_YARNRC") content, _ := os.ReadFile(filepath.Join(dir, ".yarnrc")) if string(content) != DefaultYarnrcContent { t.Errorf("got %q; want default", string(content)) } }) t.Run("unknown file type", func(t *testing.T) { dir := t.TempDir() re := &build.Request{SourceDir: dir, BuildEnvs: map[string]string{}} if err := injectConfigFile(re, ".unknown", "K"); err != nil { t.Errorf("unexpected error: %v", err) } }) } // capability_id: rainbond.cnb.npmrc-registry-parse func TestNpmRegistryFromNpmrc(t *testing.T) { tests := []struct{ name, content, want string }{ {"simple", "registry=https://registry.npmmirror.com\n", "https://registry.npmmirror.com"}, {"no trailing newline", "registry=https://registry.npmmirror.com", "https://registry.npmmirror.com"}, {"surrounding space", " registry = https://registry.npmmirror.com \n", "https://registry.npmmirror.com"}, {"quoted", `registry="https://registry.npmmirror.com"`, "https://registry.npmmirror.com"}, {"among other keys", "shamefully-hoist=true\nregistry=https://nexus.internal/repo\nstrict-peer-dependencies=false\n", "https://nexus.internal/repo"}, {"comments skipped", "# registry=https://wrong.example\n; registry=https://wrong.example\nregistry=https://right.example\n", "https://right.example"}, {"scoped only", "@myorg:registry=https://nexus.internal/repo\n", ""}, {"no registry key", "shamefully-hoist=true\n", ""}, {"empty", "", ""}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if got := npmRegistryFromNpmrc(tt.content); got != tt.want { t.Errorf("npmRegistryFromNpmrc(%q) = %q; want %q", tt.content, got, tt.want) } }) } } // capability_id: rainbond.cnb.npm-global-registry func TestResolveNpmRegistry(t *testing.T) { t.Setenv("ENABLE_CHINA_MIRROR", "true") t.Run("project npmrc wins over platform default", func(t *testing.T) { dir := newNodeDir(t) os.WriteFile(filepath.Join(dir, ".npmrc"), []byte("registry=https://nexus.internal/repo\n"), 0644) re := &build.Request{SourceDir: dir, BuildEnvs: map[string]string{"CNB_MIRROR_SOURCE": "project"}} if got := resolveNpmRegistry(re); got != "https://nexus.internal/repo" { t.Errorf("got %q; want the project registry", got) } }) t.Run("explicit env wins over project npmrc", func(t *testing.T) { dir := newNodeDir(t) os.WriteFile(filepath.Join(dir, ".npmrc"), []byte("registry=https://nexus.internal/repo\n"), 0644) re := &build.Request{SourceDir: dir, BuildEnvs: map[string]string{"CNB_NPM_REGISTRY": "https://explicit.example"}} if got := resolveNpmRegistry(re); got != "https://explicit.example" { t.Errorf("got %q; want the explicit override", got) } }) t.Run("BP_NODE_PROJECT_PATH npmrc preferred over root", func(t *testing.T) { dir := newNodeDir(t) os.WriteFile(filepath.Join(dir, ".npmrc"), []byte("registry=https://root.example\n"), 0644) sub := filepath.Join(dir, "frontend") os.MkdirAll(sub, 0755) os.WriteFile(filepath.Join(sub, ".npmrc"), []byte("registry=https://sub.example\n"), 0644) re := &build.Request{SourceDir: dir, BuildEnvs: map[string]string{"BP_NODE_PROJECT_PATH": "frontend"}} if got := resolveNpmRegistry(re); got != "https://sub.example" { t.Errorf("got %q; want the subdirectory registry", got) } }) t.Run("falls back to CNB_MIRROR_NPMRC", func(t *testing.T) { dir := newNodeDir(t) re := &build.Request{SourceDir: dir, BuildEnvs: map[string]string{"CNB_MIRROR_NPMRC": "registry=https://custom.example\n"}} if got := resolveNpmRegistry(re); got != "https://custom.example" { t.Errorf("got %q; want the CNB_MIRROR_NPMRC registry", got) } }) t.Run("falls back to china mirror default", func(t *testing.T) { dir := newNodeDir(t) re := &build.Request{SourceDir: dir, BuildEnvs: map[string]string{}} if got := resolveNpmRegistry(re); got != "https://registry.npmmirror.com" { t.Errorf("got %q; want the default mirror", got) } }) t.Run("project npmrc without registry key falls through", func(t *testing.T) { dir := newNodeDir(t) os.WriteFile(filepath.Join(dir, ".npmrc"), []byte("shamefully-hoist=true\n"), 0644) re := &build.Request{SourceDir: dir, BuildEnvs: map[string]string{}} if got := resolveNpmRegistry(re); got != "https://registry.npmmirror.com" { t.Errorf("got %q; want the default mirror", got) } }) t.Run("china mirror disabled yields nothing", func(t *testing.T) { t.Setenv("ENABLE_CHINA_MIRROR", "false") dir := newNodeDir(t) re := &build.Request{SourceDir: dir, BuildEnvs: map[string]string{}} if got := resolveNpmRegistry(re); got != "" { t.Errorf("got %q; want empty", got) } }) } // capability_id: rainbond.cnb.npm-global-registry // NPM_CONFIG_REGISTRY must reach the buildpack process: npm ignores the project // .npmrc during `npm install -g pnpm@...`, so the env var is the only lever. func TestNodejsNpmRegistryAnnotation(t *testing.T) { t.Setenv("ENABLE_CHINA_MIRROR", "true") dir := newNodeDir(t) os.WriteFile(filepath.Join(dir, ".npmrc"), []byte("registry=https://registry.npmmirror.com\n"), 0644) annotations := (&Builder{}).buildPlatformAnnotations(&build.Request{ SourceDir: dir, BuildEnvs: map[string]string{"CNB_MIRROR_SOURCE": "project", "CNB_FRAMEWORK": "vite"}, }) if got := annotations["cnb-npm-config-registry"]; got != "https://registry.npmmirror.com" { t.Fatalf("cnb-npm-config-registry = %q; want the project registry", got) } if got := annotationKeyToBPEnv("cnb-npm-config-registry"); got != "NPM_CONFIG_REGISTRY" { t.Fatalf("annotation maps to env %q; want NPM_CONFIG_REGISTRY", got) } volume, _ := (&Builder{}).createPlatformVolume(annotations, nil) if volume == nil { t.Fatal("expected a platform volume") } var found bool for _, source := range volume.VolumeSource.Projected.Sources { if source.DownwardAPI == nil { continue } for _, item := range source.DownwardAPI.Items { if item.Path == "env/NPM_CONFIG_REGISTRY" { found = true } } } if !found { t.Error("platform volume missing env/NPM_CONFIG_REGISTRY") } } // --- platform.go --- // capability_id: rainbond.cnb.annotation-key-encode func TestBpEnvToAnnotationKey(t *testing.T) { tests := []struct{ input, want string }{ {"BP_NODE_VERSION", "cnb-bp-node-version"}, {"BP_WEB_SERVER", "cnb-bp-web-server"}, {"BP_", "cnb-bp-"}, } for _, tt := range tests { if got := bpEnvToAnnotationKey(tt.input); got != tt.want { t.Errorf("bpEnvToAnnotationKey(%q) = %q; want %q", tt.input, got, tt.want) } } } // capability_id: rainbond.cnb.annotation-key-decode func TestAnnotationKeyToBPEnv(t *testing.T) { tests := []struct{ input, want string }{ {"cnb-bp-node-version", "BP_NODE_VERSION"}, {"cnb-bp-web-server", "BP_WEB_SERVER"}, {"cnb-node-env", "NODE_ENV"}, } for _, tt := range tests { if got := annotationKeyToBPEnv(tt.input); got != tt.want { t.Errorf("annotationKeyToBPEnv(%q) = %q; want %q", tt.input, got, tt.want) } } } // capability_id: rainbond.cnb.platform-annotations func TestBuildPlatformAnnotations(t *testing.T) { nodeDir := newNodeDir(t) t.Run("node.js defaults", func(t *testing.T) { ann := (&Builder{}).buildPlatformAnnotations(&build.Request{SourceDir: nodeDir, BuildEnvs: map[string]string{}}) if ann["cnb-node-env"] != "production" { t.Errorf("cnb-node-env = %q; want production", ann["cnb-node-env"]) } }) t.Run("custom NODE_ENV", func(t *testing.T) { ann := (&Builder{}).buildPlatformAnnotations(&build.Request{SourceDir: nodeDir, BuildEnvs: map[string]string{"CNB_NODE_ENV": "development"}}) if ann["cnb-node-env"] != "development" { t.Errorf("got %q", ann["cnb-node-env"]) } }) t.Run("node version", func(t *testing.T) { ann := (&Builder{}).buildPlatformAnnotations(&build.Request{SourceDir: nodeDir, BuildEnvs: map[string]string{"CNB_NODE_VERSION": "20.10.0"}}) if ann["cnb-bp-node-version"] != "20.10.0" { t.Errorf("got %q", ann["cnb-bp-node-version"]) } }) t.Run("RUNTIMES fallback", func(t *testing.T) { ann := (&Builder{}).buildPlatformAnnotations(&build.Request{SourceDir: nodeDir, BuildEnvs: map[string]string{"RUNTIMES": "18.17.0"}}) if ann["cnb-bp-node-version"] != "18.17.0" { t.Errorf("got %q", ann["cnb-bp-node-version"]) } }) t.Run("static build", func(t *testing.T) { ann := (&Builder{}).buildPlatformAnnotations(&build.Request{SourceDir: nodeDir, BuildEnvs: map[string]string{"CNB_OUTPUT_DIR": "dist"}}) if ann["cnb-bp-web-server"] != "nginx" { t.Error("expected nginx") } if ann["cnb-bp-web-server-root"] != "dist" { t.Errorf("got root %q", ann["cnb-bp-web-server-root"]) } }) t.Run("server framework skips nginx", func(t *testing.T) { ann := (&Builder{}).buildPlatformAnnotations(&build.Request{SourceDir: nodeDir, BuildEnvs: map[string]string{ "CNB_FRAMEWORK": "nestjs", "CNB_OUTPUT_DIR": "dist", }}) if _, ok := ann["cnb-bp-web-server"]; ok { t.Error("server framework should not have nginx") } }) t.Run("nextjs SSR skips nginx", func(t *testing.T) { ann := (&Builder{}).buildPlatformAnnotations(&build.Request{SourceDir: nodeDir, BuildEnvs: map[string]string{ "CNB_FRAMEWORK": "nextjs", }}) if _, ok := ann["cnb-bp-web-server"]; ok { t.Error("nextjs SSR without CNB_OUTPUT_DIR should not have nginx") } }) t.Run("nextjs static export gets nginx", func(t *testing.T) { ann := (&Builder{}).buildPlatformAnnotations(&build.Request{SourceDir: nodeDir, BuildEnvs: map[string]string{ "CNB_FRAMEWORK": "nextjs-static", "CNB_OUTPUT_DIR": "out", }}) if ann["cnb-bp-web-server"] != "nginx" { t.Error("nextjs-static should have nginx") } if ann["cnb-bp-web-server-root"] != "out" { t.Errorf("expected output dir 'out', got %q", ann["cnb-bp-web-server-root"]) } }) t.Run("nuxt static export gets nginx", func(t *testing.T) { ann := (&Builder{}).buildPlatformAnnotations(&build.Request{SourceDir: nodeDir, BuildEnvs: map[string]string{ "CNB_FRAMEWORK": "nuxt-static", "CNB_OUTPUT_DIR": "dist", }}) if ann["cnb-bp-web-server"] != "nginx" { t.Error("nuxt-static should have nginx") } }) t.Run("static framework gets nginx", func(t *testing.T) { ann := (&Builder{}).buildPlatformAnnotations(&build.Request{SourceDir: nodeDir, BuildEnvs: map[string]string{ "CNB_FRAMEWORK": "react", }}) if ann["cnb-bp-web-server"] != "nginx" { t.Error("static framework should have nginx") } if ann["cnb-bp-web-server-root"] != "dist" { t.Errorf("default output dir should be dist, got %q", ann["cnb-bp-web-server-root"]) } }) t.Run("static framework with custom output dir", func(t *testing.T) { ann := (&Builder{}).buildPlatformAnnotations(&build.Request{SourceDir: nodeDir, BuildEnvs: map[string]string{ "CNB_FRAMEWORK": "vue", "CNB_OUTPUT_DIR": "build", }}) if ann["cnb-bp-web-server-root"] != "build" { t.Errorf("got %q; want build", ann["cnb-bp-web-server-root"]) } }) t.Run("build script", func(t *testing.T) { ann := (&Builder{}).buildPlatformAnnotations(&build.Request{SourceDir: nodeDir, BuildEnvs: map[string]string{"CNB_BUILD_SCRIPT": "build:prod"}}) if ann["cnb-bp-node-run-scripts"] != "build:prod" { t.Errorf("got %q", ann["cnb-bp-node-run-scripts"]) } }) t.Run("BP_ passthrough", func(t *testing.T) { ann := (&Builder{}).buildPlatformAnnotations(&build.Request{SourceDir: nodeDir, BuildEnvs: map[string]string{"BP_CUSTOM_VAR": "val"}}) if ann["cnb-bp-custom-var"] != "val" { t.Error("BP_ passthrough failed") } }) t.Run("CNB_START_SCRIPT to BP_NPM_START_SCRIPT", func(t *testing.T) { ann := (&Builder{}).buildPlatformAnnotations(&build.Request{SourceDir: nodeDir, BuildEnvs: map[string]string{"CNB_START_SCRIPT": "start:prod"}}) if ann["cnb-bp-npm-start-script"] != "start:prod" { t.Errorf("expected start:prod, got %q", ann["cnb-bp-npm-start-script"]) } }) t.Run("CNB_START_SCRIPT with pnpm", func(t *testing.T) { ann := (&Builder{}).buildPlatformAnnotations(&build.Request{SourceDir: nodeDir, BuildEnvs: map[string]string{ "CNB_START_SCRIPT": "serve", "CNB_PACKAGE_TOOL": "pnpm", }}) if ann["cnb-bp-npm-start-script"] != "serve" { t.Errorf("expected serve, got %q", ann["cnb-bp-npm-start-script"]) } }) t.Run("BP_NPM_START_SCRIPT passthrough", func(t *testing.T) { ann := (&Builder{}).buildPlatformAnnotations(&build.Request{SourceDir: nodeDir, BuildEnvs: map[string]string{"BP_NPM_START_SCRIPT": "start:prod"}}) if ann["cnb-bp-npm-start-script"] != "start:prod" { t.Errorf("expected start:prod, got %q", ann["cnb-bp-npm-start-script"]) } }) t.Run("pure static project", func(t *testing.T) { dir := t.TempDir() ann := (&Builder{}).buildPlatformAnnotations(&build.Request{SourceDir: dir, BuildEnvs: map[string]string{}}) if ann["cnb-bp-web-server"] != "nginx" { t.Error("pure static should have nginx") } if ann["cnb-bp-web-server-root"] != "." { t.Errorf("got %q; want '.'", ann["cnb-bp-web-server-root"]) } }) t.Run("pure static with custom output dir", func(t *testing.T) { dir := t.TempDir() ann := (&Builder{}).buildPlatformAnnotations(&build.Request{SourceDir: dir, BuildEnvs: map[string]string{"CNB_OUTPUT_DIR": "public"}}) if ann["cnb-bp-web-server-root"] != "public" { t.Errorf("got %q; want public", ann["cnb-bp-web-server-root"]) } }) t.Run("static project nginx config takes precedence over generated defaults", func(t *testing.T) { for _, lang := range []code.Lang{code.Static, code.Nodejs} { t.Run(string(lang), func(t *testing.T) { dir := t.TempDir() configPath := filepath.Join(dir, "nginx.conf") config := []byte("daemon off;\nevents {}\nhttp { server { listen 8080; root /workspace; } }\n") if err := os.WriteFile(configPath, config, 0644); err != nil { t.Fatal(err) } b := &Builder{} re := &build.Request{Lang: lang, SourceDir: dir, BuildEnvs: map[string]string{"CNB_OUTPUT_DIR": "public"}} ann := b.buildPlatformAnnotations(re) for _, key := range []string{"cnb-bp-web-server", "cnb-bp-web-server-root", "cnb-bp-web-server-enable-push-state"} { if value, exists := ann[key]; exists { t.Errorf("project nginx.conf must suppress generated default %s=%q", key, value) } } vol, _ := b.createPlatformVolume(ann, nil) for _, source := range vol.Projected.Sources { if source.DownwardAPI == nil { continue } for _, item := range source.DownwardAPI.Items { if item.Path == "env/BP_WEB_SERVER" { t.Error("buildpack must not receive automatic config generation flag") } } } bps := getLanguageConfig(re).CustomOrder(re) if len(bps) != 1 || bps[0].ID != "paketo-buildpacks/nginx" { t.Fatalf("custom config still requires nginx buildpack, got %+v", bps) } got, err := os.ReadFile(configPath) if err != nil || string(got) != string(config) { t.Fatalf("project config changed: content=%q, err=%v", got, err) } }) } }) t.Run("nginx config directory does not suppress generated defaults", func(t *testing.T) { dir := t.TempDir() if err := os.Mkdir(filepath.Join(dir, "nginx.conf"), 0755); err != nil { t.Fatal(err) } ann := (&Builder{}).buildPlatformAnnotations(&build.Request{Lang: code.Static, SourceDir: dir}) if ann["cnb-bp-web-server"] != "nginx" || ann["cnb-bp-web-server-root"] != "." || ann["cnb-bp-web-server-enable-push-state"] != "true" { t.Errorf("expected generated static defaults, got %v", ann) } }) } // capability_id: rainbond.cnb.platform-volume func TestCreatePlatformVolume(t *testing.T) { b := &Builder{} dir := newNodeDir(t) re := &build.Request{SourceDir: dir, BuildEnvs: map[string]string{"CNB_NODE_VERSION": "20"}} annotations := b.buildPlatformAnnotations(re) vol, mount := b.createPlatformVolume(annotations, nil) if vol == nil || mount == nil { t.Fatal("expected volume and mount") } if vol.Name != "platform" || mount.MountPath != "/platform" { t.Errorf("vol=%q mount=%q", vol.Name, mount.MountPath) } if vol.Projected == nil || len(vol.Projected.Sources) == 0 { t.Fatal("expected projected volume sources") } if vol.Projected.Sources[0].DownwardAPI == nil || len(vol.Projected.Sources[0].DownwardAPI.Items) == 0 { t.Error("expected DownwardAPI items") } } func TestCreatePlatformVolumeWithBinding(t *testing.T) { b := &Builder{} annotations := map[string]string{ "cnb-bp-jvm-version": "17", "cnb-binding-java-maven-type": "maven", } bindings := []platformBinding{{ Name: "java-maven", Type: "maven", ConfigMapName: "java-maven", ConfigMapKey: "mavensetting", TargetFile: "settings.xml", }} vol, _ := b.createPlatformVolume(annotations, bindings) if vol == nil || vol.Projected == nil { t.Fatal("expected projected platform volume") } if len(vol.Projected.Sources) != 2 { t.Fatalf("expected 2 projected sources, got %d", len(vol.Projected.Sources)) } configSource := vol.Projected.Sources[1].ConfigMap if configSource == nil { t.Fatal("expected configmap projection for binding") } if configSource.Name != "java-maven" { t.Fatalf("expected java-maven configmap, got %q", configSource.Name) } if len(configSource.Items) != 1 || configSource.Items[0].Path != "bindings/java-maven/settings.xml" { t.Fatalf("unexpected binding projection items: %+v", configSource.Items) } } func TestBindingTypeAnnotationKeyShortensLongBindingNames(t *testing.T) { bindingName := "06c421b8d22d718065929cfdea86b232-20260401205923-procfile-9mcpf" got := bindingTypeAnnotationKey(bindingName) if len(got) > 63 { t.Fatalf("annotation key length = %d, want <= 63: %q", len(got), got) } if !strings.HasPrefix(got, "cnb-binding-") { t.Fatalf("annotation key %q missing prefix", got) } if !strings.HasSuffix(got, "-type") { t.Fatalf("annotation key %q missing suffix", got) } if got == "cnb-binding-"+bindingName+"-type" { t.Fatalf("expected long binding name to be shortened, got %q", got) } if again := bindingTypeAnnotationKey(bindingName); again != got { t.Fatalf("annotation key should be deterministic: got %q, again %q", got, again) } if other := bindingTypeAnnotationKey(bindingName + "-other"); other == got { t.Fatalf("different binding names should not share annotation key: %q", other) } } // --- job.go --- // capability_id: rainbond.cnb.env-vars func TestBuildEnvVars(t *testing.T) { b := &Builder{} t.Run("basic envs", func(t *testing.T) { envs := b.buildEnvVars(&build.Request{BuildEnvs: map[string]string{}}) m := make(map[string]string) for _, e := range envs { m[e.Name] = e.Value } if m["CNB_PLATFORM_API"] != "0.13" { t.Error("expected CNB_PLATFORM_API=0.13") } if m["DOCKER_CONFIG"] != "/home/cnb/.docker" { t.Error("expected DOCKER_CONFIG=/home/cnb/.docker") } }) t.Run("proxy settings", func(t *testing.T) { os.Setenv("HTTP_PROXY", "http://p:8080") os.Setenv("HTTPS_PROXY", "https://p:8443") os.Setenv("NO_PROXY", "localhost") defer func() { os.Unsetenv("HTTP_PROXY") os.Unsetenv("HTTPS_PROXY") os.Unsetenv("NO_PROXY") }() envs := b.buildEnvVars(&build.Request{BuildEnvs: map[string]string{}}) m := make(map[string]string) for _, e := range envs { m[e.Name] = e.Value } if m["HTTP_PROXY"] != "http://p:8080" { t.Error("HTTP_PROXY not set") } }) } // capability_id: rainbond.cnb.creator-args func TestBuildCreatorArgs(t *testing.T) { b := &Builder{} t.Run("basic args", func(t *testing.T) { dir := newNodeDir(t) re := &build.Request{SourceDir: dir, BuildEnvs: map[string]string{}} args := b.buildCreatorArgs(re, "img:v1", "run:v1") for _, r := range []string{"-app=/workspace", "-layers=/layers", "-platform=/platform"} { found := false for _, a := range args { if a == r { found = true } } if !found { t.Errorf("missing arg %s", r) } } if args[len(args)-1] != "img:v1" { t.Errorf("last arg = %q", args[len(args)-1]) } }) t.Run("pure static gets custom order", func(t *testing.T) { dir := t.TempDir() args := b.buildCreatorArgs(&build.Request{SourceDir: dir, BuildEnvs: map[string]string{}}, "img:v1", "run:v1") hasOrder := false for _, a := range args { if strings.HasPrefix(a, "-order=") { hasOrder = true } } if !hasOrder { t.Error("expected -order flag for pure static") } }) t.Run("node project uses default order", func(t *testing.T) { dir := newNodeDir(t) args := b.buildCreatorArgs(&build.Request{SourceDir: dir, BuildEnvs: map[string]string{}}, "img:v1", "run:v1") for _, a := range args { if strings.HasPrefix(a, "-order=") { t.Error("node project should use default builder order, not custom order") } } }) } // capability_id: rainbond.cnb.volume-mounts func TestCreateVolumeAndMount(t *testing.T) { b := &Builder{} re := &build.Request{SourceDir: "/tmp/src", CacheDir: "/tmp/cache"} vols, mounts := b.createVolumeAndMount(re, "secret-name") if len(vols) != 4 { t.Errorf("expected 4 volumes, got %d", len(vols)) } if len(mounts) != 4 { t.Errorf("expected 4 mounts, got %d", len(mounts)) } // Check docker-config uses secret found := false for _, v := range vols { if v.Name == "docker-config" && v.Secret != nil && v.Secret.SecretName == "secret-name" { found = true } } if !found { t.Error("expected docker-config volume with secret") } } // --- build.go --- // capability_id: rainbond.cnb.source-dir-permissions func TestSetSourceDirPermissions(t *testing.T) { b := &Builder{} t.Run("removes .git and sets permissions recursively", func(t *testing.T) { dir := t.TempDir() gitDir := filepath.Join(dir, ".git") os.Mkdir(gitDir, 0755) os.WriteFile(filepath.Join(gitDir, "config"), []byte("test"), 0644) subDir := filepath.Join(dir, "src") os.Mkdir(subDir, 0755) os.WriteFile(filepath.Join(subDir, "main.go"), []byte("package main"), 0644) b.setSourceDirPermissions(&build.Request{SourceDir: dir}) if _, err := os.Stat(gitDir); !os.IsNotExist(err) { t.Error(".git should be removed") } info, _ := os.Stat(dir) if info.Mode().Perm() != 0777 { t.Errorf("dir permissions = %o; want 0777", info.Mode().Perm()) } subInfo, _ := os.Stat(subDir) if subInfo.Mode().Perm() != 0777 { t.Errorf("subdir permissions = %o; want 0777", subInfo.Mode().Perm()) } fileInfo, _ := os.Stat(filepath.Join(subDir, "main.go")) if fileInfo.Mode().Perm() != 0777 { t.Errorf("file permissions = %o; want 0777", fileInfo.Mode().Perm()) } }) t.Run("no .git directory", func(t *testing.T) { dir := t.TempDir() if err := b.setSourceDirPermissions(&build.Request{SourceDir: dir}); err != nil { t.Fatalf("unexpected error: %v", err) } }) t.Run("keeps .git when BUILD_KEEP_GIT is true", func(t *testing.T) { dir := t.TempDir() gitDir := filepath.Join(dir, ".git") if err := os.Mkdir(gitDir, 0755); err != nil { t.Fatalf("create .git dir: %v", err) } if err := os.WriteFile(filepath.Join(gitDir, "config"), []byte("test"), 0644); err != nil { t.Fatalf("write .git config: %v", err) } err := b.setSourceDirPermissions(&build.Request{ SourceDir: dir, BuildEnvs: map[string]string{"BUILD_KEEP_GIT": "true"}, }) if err != nil { t.Fatalf("unexpected error: %v", err) } if _, err := os.Stat(gitDir); err != nil { t.Fatalf(".git should be kept when BUILD_KEEP_GIT=true, got %v", err) } }) } // capability_id: rainbond.cnb.new-builder func TestNewBuilder(t *testing.T) { b, err := NewBuilder() if err != nil { t.Fatalf("unexpected error: %v", err) } if b == nil { t.Error("expected non-nil builder") } } // capability_id: rainbond.cnb.project-file-validation func TestValidateProjectFiles(t *testing.T) { b := &Builder{} logger := event.NewLogger("test", nil) t.Run("empty lock file returns error", func(t *testing.T) { dir := t.TempDir() os.WriteFile(filepath.Join(dir, "package-lock.json"), []byte{}, 0644) re := &build.Request{SourceDir: dir, Logger: logger} if err := b.validateProjectFiles(re); err == nil { t.Error("expected error for empty lock file") } }) t.Run("valid lock file passes", func(t *testing.T) { dir := t.TempDir() os.WriteFile(filepath.Join(dir, "package-lock.json"), []byte(`{"lockfileVersion":3}`), 0644) re := &build.Request{SourceDir: dir, Logger: logger} if err := b.validateProjectFiles(re); err != nil { t.Errorf("unexpected error: %v", err) } }) t.Run("no lock file passes", func(t *testing.T) { dir := t.TempDir() re := &build.Request{SourceDir: dir, Logger: logger} if err := b.validateProjectFiles(re); err != nil { t.Errorf("unexpected error: %v", err) } }) } // noopLogger and related types removed - using event.NewLogger instead // --- waitingComplete (job.go) --- // capability_id: rainbond.cnb.waiting-complete func TestWaitingComplete(t *testing.T) { b := &Builder{} logger := event.GetTestLogger() t.Run("complete then logcomplete", func(t *testing.T) { reChan := channels.NewRingChannel(10) re := &build.Request{Logger: logger} go func() { reChan.In() <- "complete" reChan.In() <- "logcomplete" }() if err := b.waitingComplete(re, reChan); err != nil { t.Errorf("unexpected error: %v", err) } }) t.Run("logcomplete then complete", func(t *testing.T) { reChan := channels.NewRingChannel(10) re := &build.Request{Logger: logger} go func() { reChan.In() <- "logcomplete" reChan.In() <- "complete" }() if err := b.waitingComplete(re, reChan); err != nil { t.Errorf("unexpected error: %v", err) } }) t.Run("failed then logcomplete", func(t *testing.T) { reChan := channels.NewRingChannel(10) re := &build.Request{Logger: logger} go func() { reChan.In() <- "failed" reChan.In() <- "logcomplete" }() err := b.waitingComplete(re, reChan) if err == nil { t.Error("expected error for failed job") } }) t.Run("logcomplete then failed", func(t *testing.T) { reChan := channels.NewRingChannel(10) re := &build.Request{Logger: logger} go func() { reChan.In() <- "logcomplete" reChan.In() <- "failed" }() err := b.waitingComplete(re, reChan) if err == nil { t.Error("expected error for failed job") } }) t.Run("cancel then logcomplete", func(t *testing.T) { reChan := channels.NewRingChannel(10) re := &build.Request{Logger: logger} go func() { reChan.In() <- "cancel" reChan.In() <- "logcomplete" }() err := b.waitingComplete(re, reChan) if err == nil { t.Error("expected error for canceled job") } if !strings.Contains(err.Error(), "canceled") { t.Errorf("expected 'canceled' in error, got %q", err.Error()) } }) t.Run("non-string status ignored", func(t *testing.T) { reChan := channels.NewRingChannel(10) re := &build.Request{Logger: logger} go func() { reChan.In() <- 12345 reChan.In() <- "complete" reChan.In() <- "logcomplete" }() if err := b.waitingComplete(re, reChan); err != nil { t.Errorf("unexpected error: %v", err) } }) } // --- createPlatformVolume edge cases --- // capability_id: rainbond.cnb.platform-volume func TestCreatePlatformVolumeEmpty(t *testing.T) { b := &Builder{} vol, mount := b.createPlatformVolume(map[string]string{}, nil) if vol != nil || mount != nil { t.Error("expected nil for empty annotations") } } // capability_id: rainbond.cnb.platform-volume func TestCreatePlatformVolumeNonCNBKeys(t *testing.T) { b := &Builder{} vol, mount := b.createPlatformVolume(map[string]string{"other-key": "val"}, nil) if vol != nil || mount != nil { t.Error("expected nil when no cnb- prefixed keys") } } // --- writeCustomOrder error path --- // capability_id: rainbond.cnb.order-write-failure func TestWriteCustomOrderFailure(t *testing.T) { b := &Builder{} re := &build.Request{SourceDir: "/nonexistent/path/that/does/not/exist"} bps := []orderBuildpack{{ID: "bp/test"}} flag := b.writeCustomOrder(re, bps, "test") if flag != "" { t.Errorf("expected empty flag on write failure, got %q", flag) } } // --- injectMirrorConfig error propagation --- // capability_id: rainbond.cnb.mirror-config-write-error func TestInjectMirrorConfigWriteError(t *testing.T) { n := &nodejsConfig{} dir := newNodeDir(t) // Make dir read-only so WriteFile fails os.Chmod(dir, 0555) defer os.Chmod(dir, 0755) re := &build.Request{SourceDir: dir, BuildEnvs: map[string]string{ "CNB_MIRROR_NPMRC": "custom-content", }} err := n.InjectMirrorConfig(re) if err == nil { t.Error("expected error when directory is read-only") } } // --- setSourceDirPermissions with nonexistent dir --- // capability_id: rainbond.cnb.source-dir-permissions func TestSetSourceDirPermissionsNonexistent(t *testing.T) { b := &Builder{} err := b.setSourceDirPermissions(&build.Request{SourceDir: "/nonexistent/path"}) if err == nil { t.Error("expected error for nonexistent directory") } } // --- dependency mirror defaults to online URL --- // capability_id: rainbond.cnb.dependency-mirror func TestBuildPlatformAnnotationsMirrorDefault(t *testing.T) { os.Unsetenv("BP_DEPENDENCY_MIRROR") dir := newNodeDir(t) ann := (&Builder{}).buildPlatformAnnotations(&build.Request{SourceDir: dir, BuildEnvs: map[string]string{}}) if ann["cnb-bp-dependency-mirror"] != defaultOnlineMirror { t.Errorf("expected default dependency mirror %s, got %q", defaultOnlineMirror, ann["cnb-bp-dependency-mirror"]) } } // capability_id: rainbond.cnb.dependency-mirror func TestBuildPlatformAnnotationsMirrorExplicit(t *testing.T) { os.Setenv("BP_DEPENDENCY_MIRROR", "https://example.com/mirror") defer os.Unsetenv("BP_DEPENDENCY_MIRROR") dir := newNodeDir(t) ann := (&Builder{}).buildPlatformAnnotations(&build.Request{SourceDir: dir, BuildEnvs: map[string]string{}}) if ann["cnb-bp-dependency-mirror"] != "https://example.com/mirror" { t.Errorf("expected dependency mirror from env, got %q", ann["cnb-bp-dependency-mirror"]) } } // capability_id: rainbond.cnb.dependency-mirror func TestGetDependencyMirrorOffline(t *testing.T) { os.Unsetenv("BP_DEPENDENCY_MIRROR") dir := t.TempDir() // Temporarily override the marker path for testing origMarker := offlineMirrorMarker marker := filepath.Join(dir, "BP_DEPENDENCY_MIRROR") os.WriteFile(marker, []byte("file:///grdata/cnb\n"), 0644) // We can't easily override the const, so test getDependencyMirror indirectly // by setting the env var (highest priority) os.Setenv("BP_DEPENDENCY_MIRROR", "file:///grdata/cnb") defer os.Unsetenv("BP_DEPENDENCY_MIRROR") got := getDependencyMirror() if got != "file:///grdata/cnb" { t.Errorf("expected file:///grdata/cnb, got %q", got) } _ = origMarker } // --- BP_ passthrough does not override explicit keys --- // capability_id: rainbond.cnb.bp-annotation-priority func TestBuildPlatformAnnotationsBPNoOverride(t *testing.T) { dir := newNodeDir(t) ann := (&Builder{}).buildPlatformAnnotations(&build.Request{ SourceDir: dir, BuildEnvs: map[string]string{ "CNB_BUILD_SCRIPT": "build:prod", "BP_NODE_RUN_SCRIPTS": "should-not-override", }, }) if ann["cnb-bp-node-run-scripts"] != "build:prod" { t.Errorf("BP_ passthrough should not override explicit key, got %q", ann["cnb-bp-node-run-scripts"]) } } // --- mock job controller --- type mockJobCtrl struct { jobs []*corev1.Pod getJobsErr error execJobErr error execJobFn func(ctx context.Context, job *corev1.Pod, logger io.Writer, result *channels.RingChannel) error deleted []string languageBuildSettings map[string]string defaultLanguageBuildSetting string } func (m *mockJobCtrl) ExecJob(ctx context.Context, job *corev1.Pod, logger io.Writer, result *channels.RingChannel) error { if m.execJobFn != nil { return m.execJobFn(ctx, job, logger, result) } return m.execJobErr } func (m *mockJobCtrl) GetJob(name string) (*corev1.Pod, error) { return nil, nil } func (m *mockJobCtrl) GetServiceJobs(serviceID string) ([]*corev1.Pod, error) { return m.jobs, m.getJobsErr } func (m *mockJobCtrl) DeleteJob(name string) { m.deleted = append(m.deleted, name) } func (m *mockJobCtrl) GetLanguageBuildSetting(_ context.Context, _ code.Lang, name string) string { if m.languageBuildSettings == nil { return "" } return m.languageBuildSettings[name] } func (m *mockJobCtrl) GetDefaultLanguageBuildSetting(_ context.Context, _ code.Lang) string { return m.defaultLanguageBuildSetting } func newTestBuilder(ctrl *mockJobCtrl) *Builder { return &Builder{ jobCtrl: ctrl, createAuthSecret: func(re *build.Request) (corev1.Secret, error) { return corev1.Secret{}, nil }, deleteAuthSecret: func(re *build.Request, name string) {}, prepareBuildKit: func(ctx context.Context, kubeClient kubernetes.Interface, namespace, cmName, imageDomain string) error { return nil }, } } // --- stopPreBuildJob --- // capability_id: rainbond.cnb.prebuild-job-cleanup func TestStopPreBuildJob(t *testing.T) { t.Run("deletes existing jobs", func(t *testing.T) { ctrl := &mockJobCtrl{ jobs: []*corev1.Pod{ {ObjectMeta: corev1.Pod{}.ObjectMeta}, }, } ctrl.jobs[0].Name = "job-1" b := newTestBuilder(ctrl) b.stopPreBuildJob(&build.Request{ServiceID: "svc1"}) if len(ctrl.deleted) != 1 || ctrl.deleted[0] != "job-1" { t.Errorf("expected job-1 deleted, got %v", ctrl.deleted) } }) t.Run("no jobs", func(t *testing.T) { ctrl := &mockJobCtrl{} b := newTestBuilder(ctrl) b.stopPreBuildJob(&build.Request{ServiceID: "svc1"}) if len(ctrl.deleted) != 0 { t.Error("expected no deletions") } }) t.Run("error getting jobs", func(t *testing.T) { ctrl := &mockJobCtrl{getJobsErr: fmt.Errorf("api error")} b := newTestBuilder(ctrl) b.stopPreBuildJob(&build.Request{ServiceID: "svc1"}) if len(ctrl.deleted) != 0 { t.Error("expected no deletions on error") } }) } // --- runCNBBuildJob --- // capability_id: rainbond.cnb.build-job-execution func TestRunCNBBuildJob(t *testing.T) { logger := event.GetTestLogger() t.Run("exec job failure", func(t *testing.T) { ctrl := &mockJobCtrl{execJobErr: fmt.Errorf("exec failed")} b := newTestBuilder(ctrl) dir := newNodeDir(t) re := &build.Request{ ServiceID: "svc1", DeployVersion: "v1", RbdNamespace: "ns", Arch: "amd64", SourceDir: dir, CacheDir: "/tmp/cache", BuildEnvs: map[string]string{}, Logger: logger, } err := b.runCNBBuildJob(re, "img:v1") if err == nil || !strings.Contains(err.Error(), "exec failed") { t.Errorf("expected exec failed error, got %v", err) } }) t.Run("auth secret failure", func(t *testing.T) { ctrl := &mockJobCtrl{} b := &Builder{ jobCtrl: ctrl, createAuthSecret: func(re *build.Request) (corev1.Secret, error) { return corev1.Secret{}, fmt.Errorf("auth failed") }, deleteAuthSecret: func(re *build.Request, name string) {}, prepareBuildKit: func(ctx context.Context, kubeClient kubernetes.Interface, namespace, cmName, imageDomain string) error { return nil }, } dir := newNodeDir(t) re := &build.Request{ ServiceID: "svc1", DeployVersion: "v1", RbdNamespace: "ns", Arch: "amd64", SourceDir: dir, CacheDir: "/tmp/cache", BuildEnvs: map[string]string{}, Logger: logger, } err := b.runCNBBuildJob(re, "img:v1") if err == nil || !strings.Contains(err.Error(), "auth failed") { t.Errorf("expected auth failed error, got %v", err) } }) t.Run("successful job with complete", func(t *testing.T) { ctrl := &mockJobCtrl{ execJobFn: func(ctx context.Context, job *corev1.Pod, logger io.Writer, result *channels.RingChannel) error { go func() { result.In() <- "complete" result.In() <- "logcomplete" }() return nil }, } b := newTestBuilder(ctrl) dir := newNodeDir(t) re := &build.Request{ ServiceID: "svc1", DeployVersion: "v1", RbdNamespace: "ns", Arch: "amd64", SourceDir: dir, CacheDir: "/tmp/cache", BuildEnvs: map[string]string{}, Logger: logger, } err := b.runCNBBuildJob(re, "img:v1") if err != nil { t.Errorf("unexpected error: %v", err) } if len(ctrl.deleted) != 1 { t.Errorf("expected job cleanup, got %d deletions", len(ctrl.deleted)) } }) t.Run("procfile build env is injected through binding and cleaned up", func(t *testing.T) { var capturedPod *corev1.Pod ctrl := &mockJobCtrl{ execJobFn: func(ctx context.Context, job *corev1.Pod, logger io.Writer, result *channels.RingChannel) error { capturedPod = job go func() { result.In() <- "complete" result.In() <- "logcomplete" }() return nil }, } var createdConfigMap *corev1.ConfigMap var deletedConfigMapNames []string b := &Builder{ jobCtrl: ctrl, createAuthSecret: func(re *build.Request) (corev1.Secret, error) { return corev1.Secret{}, nil }, deleteAuthSecret: func(re *build.Request, name string) {}, prepareBuildKit: func(ctx context.Context, kubeClient kubernetes.Interface, namespace, cmName, imageDomain string) error { return nil }, createConfigMap: func(ctx context.Context, kubeClient kubernetes.Interface, namespace string, cm *corev1.ConfigMap) (*corev1.ConfigMap, error) { createdConfigMap = cm.DeepCopy() if createdConfigMap.Name == "" { createdConfigMap.Name = createdConfigMap.GenerateName + "abc123" } return createdConfigMap, nil }, deleteConfigMap: func(ctx context.Context, kubeClient kubernetes.Interface, namespace, name string) error { deletedConfigMapNames = append(deletedConfigMapNames, name) return nil }, } re := &build.Request{ ServiceID: "svc1", DeployVersion: "v1", RbdNamespace: "ns", Arch: "amd64", SourceDir: t.TempDir(), CacheDir: "/tmp/cache", BuildEnvs: map[string]string{ "BUILD_AUTO_PROCFILE": "web: python manage.py runserver 0.0.0.0:$_PORT", }, Logger: logger, } err := b.runCNBBuildJob(re, "img:v1") if err != nil { t.Fatalf("unexpected error: %v", err) } if capturedPod == nil { t.Fatal("pod was not captured") } var bindingName string foundBindingProjection := false for _, vol := range capturedPod.Spec.Volumes { if vol.Name != "platform" || vol.Projected == nil { continue } for _, source := range vol.Projected.Sources { if source.ConfigMap == nil || len(source.ConfigMap.Items) != 1 { continue } item := source.ConfigMap.Items[0] if !strings.HasSuffix(item.Path, "/Procfile") { continue } foundBindingProjection = true bindingName = source.ConfigMap.Name wantPath := "bindings/" + bindingName + "/Procfile" if item.Path != wantPath { t.Fatalf("procfile binding path = %q, want %q", item.Path, wantPath) } } } if !foundBindingProjection { t.Fatal("expected Procfile binding projection in CNB platform volume") } if got := capturedPod.Annotations[bindingTypeAnnotationKey(bindingName)]; got != "Procfile" { t.Fatalf("binding annotation = %q, want Procfile", got) } if createdConfigMap == nil { t.Fatal("expected temporary Procfile ConfigMap to be created") } if createdConfigMap.Data["Procfile"] != "web: python manage.py runserver 0.0.0.0:$_PORT\n" { t.Fatalf("created Procfile content = %q, want %q", createdConfigMap.Data["Procfile"], "web: python manage.py runserver 0.0.0.0:$_PORT\n") } if createdConfigMap.Name != bindingName { t.Fatalf("created ConfigMap name = %q, want %q", createdConfigMap.Name, bindingName) } if len(deletedConfigMapNames) != 1 || deletedConfigMapNames[0] != bindingName { t.Fatalf("expected temporary Procfile ConfigMap cleanup for %q, got %v", bindingName, deletedConfigMapNames) } }) t.Run("long procfile binding names keep annotation keys within kubernetes limits", func(t *testing.T) { var capturedPod *corev1.Pod ctrl := &mockJobCtrl{ execJobFn: func(ctx context.Context, job *corev1.Pod, logger io.Writer, result *channels.RingChannel) error { capturedPod = job go func() { result.In() <- "complete" result.In() <- "logcomplete" }() return nil }, } var createdConfigMap *corev1.ConfigMap b := &Builder{ jobCtrl: ctrl, createAuthSecret: func(re *build.Request) (corev1.Secret, error) { return corev1.Secret{}, nil }, deleteAuthSecret: func(re *build.Request, name string) {}, prepareBuildKit: func(ctx context.Context, kubeClient kubernetes.Interface, namespace, cmName, imageDomain string) error { return nil }, createConfigMap: func(ctx context.Context, kubeClient kubernetes.Interface, namespace string, cm *corev1.ConfigMap) (*corev1.ConfigMap, error) { createdConfigMap = cm.DeepCopy() createdConfigMap.Name = cm.GenerateName + "9mcpf" return createdConfigMap, nil }, deleteConfigMap: func(ctx context.Context, kubeClient kubernetes.Interface, namespace, name string) error { return nil }, } re := &build.Request{ ServiceID: "06c421b8d22d718065929cfdea86b232", DeployVersion: "20260401205923", RbdNamespace: "ns", Arch: "amd64", SourceDir: t.TempDir(), CacheDir: "/tmp/cache", BuildEnvs: map[string]string{ "BUILD_AUTO_PROCFILE": "web: python manage.py runserver 0.0.0.0:$_PORT", }, Logger: logger, } err := b.runCNBBuildJob(re, "img:v1") if err != nil { t.Fatalf("unexpected error: %v", err) } if capturedPod == nil { t.Fatal("pod was not captured") } if createdConfigMap == nil { t.Fatal("expected temporary Procfile ConfigMap to be created") } annotationKey := bindingTypeAnnotationKey(createdConfigMap.Name) if len(annotationKey) > 63 { t.Fatalf("annotation key length = %d, want <= 63: %q", len(annotationKey), annotationKey) } if got := capturedPod.Annotations[annotationKey]; got != "Procfile" { t.Fatalf("binding annotation = %q, want Procfile", got) } foundPlatformMount := false foundBindingFieldRef := false wantFieldPath := "metadata.annotations['" + annotationKey + "']" for _, mount := range capturedPod.Spec.Containers[0].VolumeMounts { if mount.Name == "platform" && mount.MountPath == "/platform" { foundPlatformMount = true } } for _, vol := range capturedPod.Spec.Volumes { if vol.Name != "platform" || vol.Projected == nil { continue } for _, source := range vol.Projected.Sources { if source.DownwardAPI == nil { continue } for _, item := range source.DownwardAPI.Items { if item.Path != "bindings/"+createdConfigMap.Name+"/type" { continue } foundBindingFieldRef = true if item.FieldRef == nil || item.FieldRef.FieldPath != wantFieldPath { t.Fatalf("binding type fieldRef = %+v, want %q", item.FieldRef, wantFieldPath) } } } } if !foundPlatformMount { t.Fatal("expected platform volume mount to be present") } if !foundBindingFieldRef { t.Fatal("expected binding type DownwardAPI item for long Procfile binding") } }) t.Run("CNB_START_SCRIPT in pod annotations and downward API", func(t *testing.T) { var capturedPod *corev1.Pod ctrl := &mockJobCtrl{ execJobFn: func(ctx context.Context, job *corev1.Pod, logger io.Writer, result *channels.RingChannel) error { capturedPod = job go func() { result.In() <- "complete" result.In() <- "logcomplete" }() return nil }, } b := newTestBuilder(ctrl) dir := newNodeDir(t) re := &build.Request{ ServiceID: "svc1", DeployVersion: "v1", RbdNamespace: "ns", Arch: "amd64", SourceDir: dir, CacheDir: "/tmp/cache", BuildEnvs: map[string]string{"CNB_START_SCRIPT": "start:prod"}, Logger: logger, } err := b.runCNBBuildJob(re, "img:v1") if err != nil { t.Fatalf("unexpected error: %v", err) } if capturedPod == nil { t.Fatal("pod was not captured") } if len(capturedPod.Spec.Containers) != 1 { t.Fatalf("expected 1 container, got %d", len(capturedPod.Spec.Containers)) } if capturedPod.Spec.Containers[0].ImagePullPolicy != corev1.PullIfNotPresent { t.Fatalf("container ImagePullPolicy = %q; want %q", capturedPod.Spec.Containers[0].ImagePullPolicy, corev1.PullIfNotPresent) } // Verify Pod annotation if v, ok := capturedPod.Annotations["cnb-bp-npm-start-script"]; !ok || v != "start:prod" { t.Errorf("Pod annotation cnb-bp-npm-start-script = %q (exists=%v); want start:prod", v, ok) t.Logf("All annotations: %v", capturedPod.Annotations) } // Verify DownwardAPI volume has the env file foundEnvFile := false for _, vol := range capturedPod.Spec.Volumes { if vol.Name == "platform" && vol.Projected != nil { for _, source := range vol.Projected.Sources { if source.DownwardAPI == nil { continue } for _, item := range source.DownwardAPI.Items { if item.Path == "env/BP_NPM_START_SCRIPT" { foundEnvFile = true if item.FieldRef.FieldPath != "metadata.annotations['cnb-bp-npm-start-script']" { t.Errorf("FieldPath = %q; want metadata.annotations['cnb-bp-npm-start-script']", item.FieldRef.FieldPath) } } } } } } if !foundEnvFile { t.Error("DownwardAPI volume missing env/BP_NPM_START_SCRIPT item") } }) t.Run("CNB_START_SCRIPT and BP_* passthrough in pod annotations and downward API", func(t *testing.T) { var capturedPod *corev1.Pod ctrl := &mockJobCtrl{ execJobFn: func(ctx context.Context, job *corev1.Pod, logger io.Writer, result *channels.RingChannel) error { capturedPod = job go func() { result.In() <- "complete" result.In() <- "logcomplete" }() return nil }, } b := newTestBuilder(ctrl) dir := newNodeDir(t) re := &build.Request{ ServiceID: "svc1", DeployVersion: "v1", RbdNamespace: "ns", Arch: "amd64", SourceDir: dir, CacheDir: "/tmp/cache", BuildEnvs: map[string]string{ "CNB_START_SCRIPT": "start:prod", "BP_NODE_VERSION": "20.10.0", "BP_CUSTOM_FLAG": "enabled", }, Logger: logger, } err := b.runCNBBuildJob(re, "img:v1") if err != nil { t.Fatalf("unexpected error: %v", err) } if capturedPod == nil { t.Fatal("pod was not captured") } // Verify all annotations exist on the Pod wantAnnotations := map[string]string{ "cnb-bp-npm-start-script": "start:prod", "cnb-bp-node-version": "20.10.0", "cnb-bp-custom-flag": "enabled", } for annKey, wantVal := range wantAnnotations { if got, ok := capturedPod.Annotations[annKey]; !ok { t.Errorf("missing annotation %q", annKey) } else if got != wantVal { t.Errorf("annotation %q = %q; want %q", annKey, got, wantVal) } } // Verify DownwardAPI volume has all env files wantEnvFiles := map[string]string{ "env/BP_NPM_START_SCRIPT": "metadata.annotations['cnb-bp-npm-start-script']", "env/BP_NODE_VERSION": "metadata.annotations['cnb-bp-node-version']", "env/BP_CUSTOM_FLAG": "metadata.annotations['cnb-bp-custom-flag']", } foundFiles := make(map[string]bool) for _, vol := range capturedPod.Spec.Volumes { if vol.Name == "platform" && vol.Projected != nil { for _, source := range vol.Projected.Sources { if source.DownwardAPI == nil { continue } for _, item := range source.DownwardAPI.Items { if wantField, ok := wantEnvFiles[item.Path]; ok { foundFiles[item.Path] = true if item.FieldRef.FieldPath != wantField { t.Errorf("DownwardAPI %s FieldPath = %q; want %q", item.Path, item.FieldRef.FieldPath, wantField) } } } } } } for path := range wantEnvFiles { if !foundFiles[path] { t.Errorf("DownwardAPI volume missing %s item", path) } } }) t.Run("java maven fields are visible in pod annotations and envs", func(t *testing.T) { var capturedPod *corev1.Pod ctrl := &mockJobCtrl{ defaultLanguageBuildSetting: "default-maven", execJobFn: func(ctx context.Context, job *corev1.Pod, logger io.Writer, result *channels.RingChannel) error { capturedPod = job go func() { result.In() <- "complete" result.In() <- "logcomplete" }() return nil }, } b := newTestBuilder(ctrl) re := &build.Request{ ServiceID: "svc1", DeployVersion: "v1", RbdNamespace: "ns", Arch: "amd64", SourceDir: t.TempDir(), CacheDir: "/tmp/cache", Lang: code.JavaMaven, BuildEnvs: map[string]string{ "BUILD_MAVEN_CUSTOM_GOALS": "clean package", "BUILD_MAVEN_CUSTOM_OPTS": "-DskipTests", "BUILD_MAVEN_JAVA_OPTS": "-Xmx1024m", "BUILD_MAVEN_BUILT_MODULE": "service-a", "BUILD_MAVEN_BUILT_ARTIFACT": "service-a/target/app.jar", }, Logger: logger, } err := b.runCNBBuildJob(re, "img:v1") if err != nil { t.Fatalf("unexpected error: %v", err) } if capturedPod == nil { t.Fatal("pod was not captured") } if len(capturedPod.Spec.Containers) != 1 { t.Fatalf("expected 1 container, got %d", len(capturedPod.Spec.Containers)) } wantAnnotations := map[string]string{ "cnb-bp-maven-build-arguments": "clean package", "cnb-bp-maven-additional-build-arguments": "-DskipTests", "cnb-bp-maven-built-module": "service-a", "cnb-bp-maven-built-artifact": "service-a/target/app.jar", "cnb-bp-maven-settings-path": "/platform/bindings/default-maven/settings.xml", "cnb-lang": "C.UTF-8", "cnb-lc-all": "C.UTF-8", "cnb-maven-opts": "-Xmx1024m -Dfile.encoding=UTF-8 -Dsun.jnu.encoding=UTF-8", } for key, wantValue := range wantAnnotations { if got := capturedPod.Annotations[key]; got != wantValue { t.Fatalf("annotation %s = %q, want %q", key, got, wantValue) } } wantEnv := map[string]string{ "BP_MAVEN_BUILD_ARGUMENTS": "clean package", "BP_MAVEN_ADDITIONAL_BUILD_ARGUMENTS": "-DskipTests", "BP_MAVEN_BUILT_MODULE": "service-a", "BP_MAVEN_BUILT_ARTIFACT": "service-a/target/app.jar", "MAVEN_OPTS": "-Xmx1024m -Dfile.encoding=UTF-8 -Dsun.jnu.encoding=UTF-8", } foundEnv := map[string]bool{} for _, env := range capturedPod.Spec.Containers[0].Env { if wantValue, ok := wantEnv[env.Name]; ok { foundEnv[env.Name] = true if env.Value != wantValue { t.Fatalf("env %s = %q, want %q", env.Name, env.Value, wantValue) } } } for name := range wantEnv { if !foundEnv[name] { t.Fatalf("missing env %s in CNB pod", name) } } wantPlatformEnvFiles := map[string]string{ "env/LANG": "metadata.annotations['cnb-lang']", "env/LC_ALL": "metadata.annotations['cnb-lc-all']", "env/MAVEN_OPTS": "metadata.annotations['cnb-maven-opts']", } foundPlatformEnvFiles := map[string]bool{} for _, vol := range capturedPod.Spec.Volumes { if vol.Name != "platform" || vol.Projected == nil { continue } for _, source := range vol.Projected.Sources { if source.DownwardAPI == nil { continue } for _, item := range source.DownwardAPI.Items { if wantField, ok := wantPlatformEnvFiles[item.Path]; ok { foundPlatformEnvFiles[item.Path] = true if item.FieldRef == nil || item.FieldRef.FieldPath != wantField { t.Fatalf("platform env %s fieldRef = %+v, want %q", item.Path, item.FieldRef, wantField) } } } } } for path := range wantPlatformEnvFiles { if !foundPlatformEnvFiles[path] { t.Fatalf("missing platform env file %s in CNB pod", path) } } }) t.Run("java maven derives built module from built artifact in pod", func(t *testing.T) { var capturedPod *corev1.Pod ctrl := &mockJobCtrl{ defaultLanguageBuildSetting: "default-maven", execJobFn: func(ctx context.Context, job *corev1.Pod, logger io.Writer, result *channels.RingChannel) error { capturedPod = job go func() { result.In() <- "complete" result.In() <- "logcomplete" }() return nil }, } b := newTestBuilder(ctrl) re := &build.Request{ ServiceID: "svc1", DeployVersion: "v1", RbdNamespace: "ns", Arch: "amd64", SourceDir: t.TempDir(), CacheDir: "/tmp/cache", Lang: code.JavaMaven, BuildEnvs: map[string]string{ "BUILD_MAVEN_BUILT_ARTIFACT": "ruoyi-admin/target/ruoyi-admin.jar", }, Logger: logger, } err := b.runCNBBuildJob(re, "img:v1") if err != nil { t.Fatalf("unexpected error: %v", err) } if capturedPod == nil { t.Fatal("pod was not captured") } if got := capturedPod.Annotations["cnb-bp-maven-built-module"]; got != "ruoyi-admin" { t.Fatalf("annotation cnb-bp-maven-built-module = %q, want %q", got, "ruoyi-admin") } if got := capturedPod.Annotations["cnb-bp-maven-built-artifact"]; got != "ruoyi-admin/target/ruoyi-admin.jar" { t.Fatalf("annotation cnb-bp-maven-built-artifact = %q, want %q", got, "ruoyi-admin/target/ruoyi-admin.jar") } foundEnv := map[string]string{} for _, env := range capturedPod.Spec.Containers[0].Env { foundEnv[env.Name] = env.Value } if got := foundEnv["BP_MAVEN_BUILT_MODULE"]; got != "ruoyi-admin" { t.Fatalf("env BP_MAVEN_BUILT_MODULE = %q, want %q", got, "ruoyi-admin") } if got := foundEnv["BP_MAVEN_BUILT_ARTIFACT"]; got != "ruoyi-admin/target/ruoyi-admin.jar" { t.Fatalf("env BP_MAVEN_BUILT_ARTIFACT = %q, want %q", got, "ruoyi-admin/target/ruoyi-admin.jar") } }) } // --- config.go: offline mode --- // capability_id: rainbond.cnb.offline-mode func TestIsOfflineMode(t *testing.T) { t.Run("offline when marker file exists", func(t *testing.T) { dir := t.TempDir() marker := filepath.Join(dir, "BP_DEPENDENCY_MIRROR") os.WriteFile(marker, []byte("file:///grdata/cnb"), 0644) orig := offlineMirrorMarker offlineMirrorMarker = marker defer func() { offlineMirrorMarker = orig }() if !isOfflineMode() { t.Error("expected offline mode when marker file exists") } }) t.Run("online when marker file absent", func(t *testing.T) { orig := offlineMirrorMarker offlineMirrorMarker = "/nonexistent/path/marker" defer func() { offlineMirrorMarker = orig }() if isOfflineMode() { t.Error("expected online mode when marker file absent") } }) } // capability_id: rainbond.cnb.offline-mode func TestGetCNBBuilderImageOffline(t *testing.T) { tests := []struct { name string envVar string offline bool registryDomain string want string }{ { name: "default online", want: DefaultCNBBuilder, }, { name: "env var override", envVar: "custom-registry.com/builder:v1", want: "custom-registry.com/builder:v1", }, { name: "offline with goodrain.me", offline: true, registryDomain: "goodrain.me", want: "goodrain.me/" + cnbBuilderShortName, }, { name: "offline with custom registry", offline: true, registryDomain: "harbor.example.com/lib", want: "harbor.example.com/lib/" + cnbBuilderShortName, }, { name: "env var takes priority over offline", envVar: "custom:v1", offline: true, registryDomain: "goodrain.me", want: "custom:v1", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { os.Unsetenv("CNB_BUILDER_IMAGE") if tt.envVar != "" { os.Setenv("CNB_BUILDER_IMAGE", tt.envVar) defer os.Unsetenv("CNB_BUILDER_IMAGE") } orig := offlineMirrorMarker if tt.offline { dir := t.TempDir() marker := filepath.Join(dir, "marker") os.WriteFile(marker, []byte("file:///grdata/cnb"), 0644) offlineMirrorMarker = marker } else { offlineMirrorMarker = "/nonexistent/marker" } defer func() { offlineMirrorMarker = orig }() if tt.registryDomain != "" { origDomain := builder.REGISTRYDOMAIN builder.REGISTRYDOMAIN = tt.registryDomain defer func() { builder.REGISTRYDOMAIN = origDomain }() } if got := GetCNBBuilderImage(); got != tt.want { t.Errorf("GetCNBBuilderImage() = %q; want %q", got, tt.want) } }) } } // capability_id: rainbond.cnb.offline-mode func TestGetCNBRunImageOffline(t *testing.T) { tests := []struct { name string envVar string offline bool registryDomain string want string }{ { name: "default online", want: DefaultCNBRunImage, }, { name: "offline with goodrain.me", offline: true, registryDomain: "goodrain.me", want: "goodrain.me/" + cnbRunShortName, }, { name: "offline with custom registry", offline: true, registryDomain: "harbor.example.com/lib", want: "harbor.example.com/lib/" + cnbRunShortName, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { os.Unsetenv("CNB_RUN_IMAGE") if tt.envVar != "" { os.Setenv("CNB_RUN_IMAGE", tt.envVar) defer os.Unsetenv("CNB_RUN_IMAGE") } orig := offlineMirrorMarker if tt.offline { dir := t.TempDir() marker := filepath.Join(dir, "marker") os.WriteFile(marker, []byte("file:///grdata/cnb"), 0644) offlineMirrorMarker = marker } else { offlineMirrorMarker = "/nonexistent/marker" } defer func() { offlineMirrorMarker = orig }() if tt.registryDomain != "" { origDomain := builder.REGISTRYDOMAIN builder.REGISTRYDOMAIN = tt.registryDomain defer func() { builder.REGISTRYDOMAIN = origDomain }() } if got := GetCNBRunImage(); got != tt.want { t.Errorf("GetCNBRunImage() = %q; want %q", got, tt.want) } }) } } // --- job.go: insecure-registry for run image --- // capability_id: rainbond.cnb.creator-args-insecure-registry func TestBuildCreatorArgsInsecureRegistry(t *testing.T) { b := &Builder{} t.Run("same registry - single insecure-registry", func(t *testing.T) { origDomain := builder.REGISTRYDOMAIN builder.REGISTRYDOMAIN = "goodrain.me" defer func() { builder.REGISTRYDOMAIN = origDomain }() dir := newNodeDir(t) re := &build.Request{SourceDir: dir, BuildEnvs: map[string]string{}} args := b.buildCreatorArgs(re, "goodrain.me/app:v1", "goodrain.me/run:v1") count := 0 for _, a := range args { if strings.HasPrefix(a, "-insecure-registry=") { count++ } } if count != 1 { t.Errorf("expected 1 insecure-registry flag, got %d; args=%v", count, args) } }) t.Run("different registry - only local insecure-registry", func(t *testing.T) { origDomain := builder.REGISTRYDOMAIN builder.REGISTRYDOMAIN = "goodrain.me" defer func() { builder.REGISTRYDOMAIN = origDomain }() dir := newNodeDir(t) re := &build.Request{SourceDir: dir, BuildEnvs: map[string]string{}} args := b.buildCreatorArgs(re, "goodrain.me/app:v1", "registry.cn-hangzhou.aliyuncs.com/goodrain/run:v1") insecureFlags := []string{} for _, a := range args { if strings.HasPrefix(a, "-insecure-registry=") { insecureFlags = append(insecureFlags, a) } } if len(insecureFlags) != 1 { t.Errorf("expected 1 insecure-registry flag (local only), got %d; flags=%v", len(insecureFlags), insecureFlags) } for _, f := range insecureFlags { if strings.Contains(f, "registry.cn-hangzhou.aliyuncs.com") { t.Errorf("external registry should NOT be marked as insecure; flags=%v", insecureFlags) } } }) }