Ctrl K
ComunidadGuías…

builder/build/cnb/package_workspace_test.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
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)
	}
}