builder/build/cnb/package_workspace_test.go
Rama main · réplica de lectura del repositorio original.
package cnb
import (
"archive/zip"
"bytes"
"context"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"testing"
"github.com/eapache/channels"
"github.com/goodrain/rainbond/builder/build"
"github.com/goodrain/rainbond/builder/parser/code"
"github.com/goodrain/rainbond/db"
dbdao "github.com/goodrain/rainbond/db/dao"
dbmodel "github.com/goodrain/rainbond/db/model"
"github.com/goodrain/rainbond/event"
corev1 "k8s.io/api/core/v1"
)
type packageWorkspaceTestManager struct {
db.Manager
}
func (m packageWorkspaceTestManager) TenantServiceDao() dbdao.TenantServiceDao {
return packageWorkspaceTestServices{}
}
type packageWorkspaceTestServices struct {
dbdao.TenantServiceDao
}
func (packageWorkspaceTestServices) GetServiceByID(string) (*dbmodel.TenantServices, error) {
return nil, errors.New("fixture has no database component")
}
func usePackageWorkspaceTestDB(t *testing.T) {
t.Helper()
previous := db.GetManager()
db.SetTestManager(packageWorkspaceTestManager{})
t.Cleanup(func() { db.SetTestManager(previous) })
}
func writeCNBSourceArchive(t *testing.T, dir string, files map[string]string) string {
t.Helper()
name := "source.zip"
file, err := os.Create(filepath.Join(dir, name))
if err != nil {
t.Fatal(err)
}
writer := zip.NewWriter(file)
for name, content := range files {
header := &zip.FileHeader{Name: name, Method: zip.Deflate}
header.SetMode(0644)
entry, err := writer.CreateHeader(header)
if err != nil {
t.Fatal(err)
}
if _, err := io.WriteString(entry, content); err != nil {
t.Fatal(err)
}
}
if err := writer.Close(); err != nil {
t.Fatal(err)
}
if err := file.Close(); err != nil {
t.Fatal(err)
}
return name
}
// capability_id: rainbond.cnb.package-workspace-isolation
func TestCNBPackageBuildUsesPristineWorkspace(t *testing.T) {
usePackageWorkspaceTestDB(t)
for _, tc := range []struct {
name string
locked bool
vendored bool
fail bool
}{
{name: "unlocked repeated build"},
{name: "archive lock is preserved", locked: true},
{name: "archive modules are preserved", vendored: true},
{name: "failed builds also release workspace", fail: true},
} {
t.Run(tc.name, func(t *testing.T) {
source := newNodeDir(t)
files := map[string]string{
"package.json": `{"name":"package-demo","version":"1.0.0"}`,
"src/main.js": "console.log('from archive');",
}
if tc.locked {
files["package-lock.json"] = `{"lockfileVersion":3,"name":"package-demo"}`
}
if tc.vendored {
files["node_modules/from-archive.txt"] = "user supplied dependency"
}
name := writeCNBSourceArchive(t, source, files)
archiveBefore, err := os.ReadFile(filepath.Join(source, name))
if err != nil {
t.Fatal(err)
}
// Reproduce the retained directory from the previous CNB job.
if err := os.WriteFile(filepath.Join(source, "package-lock.json"), []byte("generated lock"), 0644); err != nil {
t.Fatal(err)
}
if err := os.Symlink(filepath.Join(t.TempDir(), "missing-layer", "node_modules"), filepath.Join(source, "node_modules")); err != nil {
t.Fatal(err)
}
if err := os.Mkdir(filepath.Join(source, "dist"), 0755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(source, "dist", "stale.txt"), []byte("old output"), 0644); err != nil {
t.Fatal(err)
}
var workspaces []string
ctrl := &mockJobCtrl{execJobFn: func(ctx context.Context, pod *corev1.Pod, output io.Writer, result *channels.RingChannel) error {
workspace := ""
for _, volume := range pod.Spec.Volumes {
if volume.Name == "workspace" && volume.HostPath != nil {
workspace = strings.TrimPrefix(volume.HostPath.Path, "/opt/rainbond")
}
}
if workspace == "" {
return fmt.Errorf("workspace mount missing")
}
workspaces = append(workspaces, workspace)
if workspace == source {
t.Error("CNB job reused the retained package directory")
}
for name, want := range files {
got, err := os.ReadFile(filepath.Join(workspace, name))
if err != nil || string(got) != want {
t.Errorf("archive file %s = %q, %v; want %q", name, got, err, want)
}
}
for _, name := range []string{"dist/stale.txt", "node_modules/generated.txt"} {
if _, err := os.Lstat(filepath.Join(workspace, name)); !os.IsNotExist(err) {
t.Errorf("previous build output %s leaked into new job: %v", name, err)
}
}
if !tc.locked {
if _, err := os.Lstat(filepath.Join(workspace, "package-lock.json")); !os.IsNotExist(err) {
t.Errorf("generated lock leaked into unlocked build: %v", err)
}
for _, arg := range pod.Spec.Containers[0].Args {
if strings.HasPrefix(arg, "-cache-image=") && !tc.vendored {
t.Errorf("generated files changed the cache policy: %s", arg)
}
}
}
modules := filepath.Join(workspace, "node_modules")
// This is the filesystem operation that failed with "file exists"
// when npm ci encountered the previous job's dangling symlink.
if err := os.MkdirAll(modules, 0755); err != nil {
return fmt.Errorf("create node_modules: %w", err)
}
if err := os.WriteFile(filepath.Join(modules, "generated.txt"), []byte("build output"), 0644); err != nil {
return err
}
if err := os.WriteFile(filepath.Join(workspace, "package-lock.json"), []byte("new generated lock"), 0644); err != nil {
return err
}
if tc.fail {
return fmt.Errorf("synthetic build failure")
}
go func() {
result.In() <- "complete"
result.In() <- "logcomplete"
}()
return nil
}}
b := newTestBuilder(ctrl)
re := &build.Request{
ServiceID: "package-demo", RbdNamespace: "ns", Arch: "amd64",
SourceDir: source, ServerType: "pkg", Lang: code.Nodejs,
BuildStrategy: "cnb",
Commit: build.Commit{Message: name}, BuildEnvs: map[string]string{},
Logger: event.GetTestLogger(),
}
for attempt := 0; attempt < 2; attempt++ {
re.DeployVersion = fmt.Sprintf("build-%d", attempt)
_, err := b.Build(re)
if (err != nil) != tc.fail {
t.Errorf("build %d error = %v, expected failure = %v", attempt, err, tc.fail)
}
if re.SourceDir != source {
t.Errorf("build changed the caller's source reference: %s", re.SourceDir)
}
}
if len(workspaces) != 2 {
t.Fatalf("expected two build jobs, got %d", len(workspaces))
}
if workspaces[0] == workspaces[1] {
t.Error("repeated builds shared one workspace")
}
for _, workspace := range workspaces {
if workspace != source {
if _, err := os.Stat(workspace); !os.IsNotExist(err) {
t.Errorf("temporary build workspace not released: %s, %v", workspace, err)
}
}
}
archiveAfter, err := os.ReadFile(filepath.Join(source, name))
if err != nil || !bytes.Equal(archiveBefore, archiveAfter) {
t.Fatalf("original uploaded archive changed: %v", err)
}
lock, err := os.ReadFile(filepath.Join(source, "package-lock.json"))
if err != nil || string(lock) != "generated lock" {
t.Errorf("retained input directory was modified: %q, %v", lock, err)
}
})
}
for _, failure := range []string{"corrupt archive", "empty lock", "version policy"} {
t.Run(failure+" releases workspace before job", func(t *testing.T) {
source := newNodeDir(t)
contents := map[string]string{"package.json": `{"name":"package-demo"}`}
if failure == "empty lock" {
contents["package-lock.json"] = ""
}
name := writeCNBSourceArchive(t, source, contents)
if failure == "corrupt archive" {
if err := os.WriteFile(filepath.Join(source, name), []byte("invalid zip"), 0644); err != nil {
t.Fatal(err)
}
}
ctrl := &mockJobCtrl{execJobFn: func(context.Context, *corev1.Pod, io.Writer, *channels.RingChannel) error {
t.Error("invalid input reached the build job")
return errors.New("unexpected build job")
}}
re := &build.Request{SourceDir: source, ServerType: "pkg", Lang: code.Nodejs,
Commit: build.Commit{Message: name}, Logger: event.GetTestLogger()}
if failure == "version policy" {
re.BuildStrategy = "cnb"
re.BuildEnvs = map[string]string{"CNB_NODE_VERSION": "20.20.0"}
re.CNBVersionPolicy = &build.CNBVersionPolicy{Languages: map[string]build.CNBLanguagePolicy{
"nodejs": {AllowedVersions: []string{"24.13.0"}},
}}
}
if _, err := newTestBuilder(ctrl).Build(re); err == nil {
t.Fatal("invalid package/configuration was accepted")
}
entries, err := os.ReadDir(filepath.Dir(source))
if err != nil {
t.Fatal(err)
}
for _, entry := range entries {
if strings.HasPrefix(entry.Name(), ".cnb-package-") {
t.Errorf("early failure leaked a workspace: %s", entry.Name())
}
}
if re.SourceDir != source {
t.Errorf("early failure changed caller source: %s", re.SourceDir)
}
})
}
}
// capability_id: rainbond.cnb.package-workspace-isolation
func TestCNBNonPackageBuildKeepsSourceDirectory(t *testing.T) {
usePackageWorkspaceTestDB(t)
source := newNodeDir(t)
var mounted string
ctrl := &mockJobCtrl{execJobFn: func(ctx context.Context, pod *corev1.Pod, output io.Writer, result *channels.RingChannel) error {
for _, volume := range pod.Spec.Volumes {
if volume.Name == "workspace" {
mounted = volume.HostPath.Path
}
}
go func() {
result.In() <- "complete"
result.In() <- "logcomplete"
}()
return nil
}}
_, err := newTestBuilder(ctrl).Build(&build.Request{
ServiceID: "git-demo", DeployVersion: "v1", SourceDir: source,
ServerType: "git", Lang: code.Nodejs, Logger: event.GetTestLogger(),
Commit: build.Commit{Message: "ordinary git commit"},
})
if err != nil {
t.Fatal(err)
}
if mounted != filepath.Join("/opt/rainbond", source) {
t.Errorf("non-package source mount changed: %q", mounted)
}
if _, err := os.Stat(source); err != nil {
t.Fatalf("non-package source was removed: %v", err)
}
}