Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 77 additions & 0 deletions pkg/leeway/build.go
Original file line number Diff line number Diff line change
Expand Up @@ -707,6 +707,31 @@ func Build(pkg *Package, opts ...BuildOption) (err error) {
}
}

// Validate that cached packages have all their dependencies available.
// This prevents build failures when a package is cached but a dependency failed to download.
// If a cached package has missing dependencies, remove it from cache and mark for rebuild.
for _, p := range allpkg {
status := pkgstatus[p]
if status != PackageDownloaded && status != PackageBuilt {
// Only validate packages that are in the local cache
continue
}

if !validateDependenciesAvailable(p, ctx.LocalCache, pkgstatus) {
log.WithField("package", p.FullName()).Warn("Cached package has missing dependencies, will rebuild")

// Remove the package from local cache
if path, exists := ctx.LocalCache.Location(p); exists {
if err := os.Remove(path); err != nil {
log.WithError(err).WithField("package", p.FullName()).Warn("Failed to remove package from cache")
}
}

// Mark for rebuild
pkgstatus[p] = PackageNotBuiltYet
}
}

ctx.Reporter.BuildStarted(pkg, pkgstatus)
defer func(err *error) {
ctx.Reporter.BuildFinished(pkg, *err)
Expand Down Expand Up @@ -1303,6 +1328,58 @@ func (p *Package) packagesToDownload(inLocalCache map[*Package]struct{}, inRemot
}
}

// validateDependenciesAvailable checks if all required dependencies of a package are available.
// A dependency is considered available if it's in the local cache OR will be built (PackageNotBuiltYet).
// Returns true if all dependencies are available, false otherwise.
//
// This validation ensures cache consistency: a package should only remain in cache
// if all its dependencies are also available. This prevents build failures when
// a package is cached but one of its dependencies failed to download.
func validateDependenciesAvailable(p *Package, localCache cache.LocalCache, pkgstatus map[*Package]PackageBuildStatus) bool {
var deps []*Package
switch p.Type {
case YarnPackage, GoPackage:
// Go and Yarn packages need all transitive dependencies
deps = p.GetTransitiveDependencies()
case GenericPackage, DockerPackage:
// Generic and Docker packages only need direct dependencies
deps = p.GetDependencies()
default:
deps = p.GetDependencies()
}

for _, dep := range deps {
if dep.Ephemeral {
// Ephemeral packages are always rebuilt, skip validation
continue
}

_, inCache := localCache.Location(dep)
status := pkgstatus[dep]

// Dependency is available if:
// 1. It's in the local cache (PackageBuilt or PackageDownloaded), OR
// 2. It will be built locally (PackageNotBuiltYet), OR
// 3. It will be downloaded (PackageInRemoteCache)
depAvailable := inCache ||
status == PackageNotBuiltYet ||
status == PackageInRemoteCache ||
status == PackageBuilt ||
status == PackageDownloaded

if !depAvailable {
log.WithFields(log.Fields{
"package": p.FullName(),
"dependency": dep.FullName(),
"depStatus": status,
"inCache": inCache,
}).Debug("Dependency not available for cached package")
return false
}
}
return true
}

type PackageBuildPhase string

const (
Expand Down
263 changes: 263 additions & 0 deletions pkg/leeway/build_integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ package leeway
import (
"archive/tar"
"compress/gzip"
"context"
"crypto/sha256"
"encoding/json"
"errors"
Expand All @@ -18,6 +19,7 @@ import (
"strings"
"testing"

"github.com/gitpod-io/leeway/pkg/leeway/cache"
"github.com/gitpod-io/leeway/pkg/leeway/cache/local"
)

Expand Down Expand Up @@ -2186,3 +2188,264 @@ CMD ["echo", "test"]`
t.Log("✅ SBOM generation correctly respects user env var override of package config")
}
}

// mockRemoteCacheWithFailures implements cache.RemoteCache for testing dependency validation.
// It simulates a remote cache where some packages exist but fail to download.
type mockRemoteCacheWithFailures struct {
existingPackages map[string]struct{} // packages that "exist" in remote cache
failDownload map[string]struct{} // packages that fail to download
downloaded map[string]struct{} // track which packages were downloaded
}

func newMockRemoteCacheWithFailures() *mockRemoteCacheWithFailures {
return &mockRemoteCacheWithFailures{
existingPackages: make(map[string]struct{}),
failDownload: make(map[string]struct{}),
downloaded: make(map[string]struct{}),
}
}

func (m *mockRemoteCacheWithFailures) ExistingPackages(ctx context.Context, pkgs []cache.Package) (map[cache.Package]struct{}, error) {
result := make(map[cache.Package]struct{})
for _, pkg := range pkgs {
if _, exists := m.existingPackages[pkg.FullName()]; exists {
result[pkg] = struct{}{}
}
}
return result, nil
}

func (m *mockRemoteCacheWithFailures) Download(ctx context.Context, dst cache.LocalCache, pkgs []cache.Package) error {
for _, pkg := range pkgs {
if _, shouldFail := m.failDownload[pkg.FullName()]; shouldFail {
// Simulate download failure - don't copy to local cache
continue
}
if _, exists := m.existingPackages[pkg.FullName()]; exists {
// Simulate successful download by creating a dummy cache file
m.downloaded[pkg.FullName()] = struct{}{}
// Note: We don't actually create files here because we're testing
// the validation logic, not the actual download
}
}
return nil // Download returns nil even on failures (by design)
}

func (m *mockRemoteCacheWithFailures) Upload(ctx context.Context, src cache.LocalCache, pkgs []cache.Package) error {
return nil
}

func (m *mockRemoteCacheWithFailures) UploadFile(ctx context.Context, filePath string, key string) error {
return nil
}

func (m *mockRemoteCacheWithFailures) HasFile(ctx context.Context, key string) (bool, error) {
return false, nil
}

// TestDependencyValidation_AfterDownload_Integration tests that packages with missing
// dependencies are invalidated after the download phase.
//
// Scenario:
// - Package X (Go) depends on A
// - A depends on B
// - A and B both "exist" in remote cache
// - A downloads successfully, B fails to download
// - Expected: A should be invalidated (removed from cache) because its dependency B is missing
func TestDependencyValidation_AfterDownload_Integration(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test in short mode")
}

// Create temporary workspace
tmpDir := t.TempDir()

// Initialize git repository
gitInit := exec.Command("git", "init")
gitInit.Dir = tmpDir
gitInit.Env = append(os.Environ(), "GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_SYSTEM=/dev/null")
if err := gitInit.Run(); err != nil {
t.Fatalf("Failed to initialize git repository: %v", err)
}

gitConfigName := exec.Command("git", "config", "user.name", "Test User")
gitConfigName.Dir = tmpDir
if err := gitConfigName.Run(); err != nil {
t.Fatalf("Failed to configure git user.name: %v", err)
}

gitConfigEmail := exec.Command("git", "config", "user.email", "test@example.com")
gitConfigEmail.Dir = tmpDir
if err := gitConfigEmail.Run(); err != nil {
t.Fatalf("Failed to configure git user.email: %v", err)
}

// Create WORKSPACE.yaml
workspaceYAML := `defaultTarget: "pkgX:app"`
workspacePath := filepath.Join(tmpDir, "WORKSPACE.yaml")
if err := os.WriteFile(workspacePath, []byte(workspaceYAML), 0644); err != nil {
t.Fatal(err)
}

// Create package B (leaf dependency)
pkgBDir := filepath.Join(tmpDir, "pkgB")
if err := os.MkdirAll(pkgBDir, 0755); err != nil {
t.Fatal(err)
}
buildYAMLB := `packages:
- name: lib
type: generic
srcs:
- "*.txt"
config:
commands:
- ["echo", "building B"]`
if err := os.WriteFile(filepath.Join(pkgBDir, "BUILD.yaml"), []byte(buildYAMLB), 0644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(pkgBDir, "b.txt"), []byte("B content"), 0644); err != nil {
t.Fatal(err)
}

// Create package A (depends on B)
pkgADir := filepath.Join(tmpDir, "pkgA")
if err := os.MkdirAll(pkgADir, 0755); err != nil {
t.Fatal(err)
}
buildYAMLA := `packages:
- name: lib
type: generic
srcs:
- "*.txt"
deps:
- pkgB:lib
config:
commands:
- ["echo", "building A"]`
if err := os.WriteFile(filepath.Join(pkgADir, "BUILD.yaml"), []byte(buildYAMLA), 0644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(pkgADir, "a.txt"), []byte("A content"), 0644); err != nil {
t.Fatal(err)
}

// Create package X (depends on A) - using Go type to require transitive deps
pkgXDir := filepath.Join(tmpDir, "pkgX")
if err := os.MkdirAll(pkgXDir, 0755); err != nil {
t.Fatal(err)
}
// Use generic type but the validation logic should still work
buildYAMLX := `packages:
- name: app
type: generic
srcs:
- "*.txt"
deps:
- pkgA:lib
config:
commands:
- ["echo", "building X"]`
if err := os.WriteFile(filepath.Join(pkgXDir, "BUILD.yaml"), []byte(buildYAMLX), 0644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(pkgXDir, "x.txt"), []byte("X content"), 0644); err != nil {
t.Fatal(err)
}

// Create initial git commit
gitAdd := exec.Command("git", "add", ".")
gitAdd.Dir = tmpDir
if err := gitAdd.Run(); err != nil {
t.Fatalf("Failed to git add: %v", err)
}

gitCommit := exec.Command("git", "commit", "-m", "initial")
gitCommit.Dir = tmpDir
gitCommit.Env = append(os.Environ(),
"GIT_CONFIG_GLOBAL=/dev/null",
"GIT_AUTHOR_DATE=2021-01-01T00:00:00Z",
"GIT_COMMITTER_DATE=2021-01-01T00:00:00Z",
)
if err := gitCommit.Run(); err != nil {
t.Fatalf("Failed to git commit: %v", err)
}

// Load workspace
workspace, err := FindWorkspace(tmpDir, Arguments{}, "", "")
if err != nil {
t.Fatal(err)
}

// Get packages
pkgX, ok := workspace.Packages["pkgX:app"]
if !ok {
t.Fatal("package pkgX:app not found")
}
pkgA, ok := workspace.Packages["pkgA:lib"]
if !ok {
t.Fatal("package pkgA:lib not found")
}
pkgB, ok := workspace.Packages["pkgB:lib"]
if !ok {
t.Fatal("package pkgB:lib not found")
}

t.Logf("Package X: %s", pkgX.FullName())
t.Logf("Package A: %s (depends on B)", pkgA.FullName())
t.Logf("Package B: %s (leaf)", pkgB.FullName())

// Create local cache
cacheDir := filepath.Join(tmpDir, ".cache")
localCache, err := local.NewFilesystemCache(cacheDir)
if err != nil {
t.Fatal(err)
}

// Create mock remote cache where A and B "exist" but B fails to download
mockRemote := newMockRemoteCacheWithFailures()
mockRemote.existingPackages[pkgA.FullName()] = struct{}{}
mockRemote.existingPackages[pkgB.FullName()] = struct{}{}
mockRemote.failDownload[pkgB.FullName()] = struct{}{} // B will fail to download

t.Log("Mock remote cache configured:")
t.Logf(" - %s: exists, will download successfully", pkgA.FullName())
t.Logf(" - %s: exists, will FAIL to download", pkgB.FullName())

// Create build context with mock remote cache
buildCtx, err := newBuildContext(buildOptions{
LocalCache: localCache,
RemoteCache: mockRemote,
Reporter: NewConsoleReporter(),
})
if err != nil {
t.Fatal(err)
}

// Build package X
// With the fix: A should be invalidated because B failed to download
// Without the fix: A would remain in cache with missing dependency B
err = pkgX.build(buildCtx)

// The build should succeed because:
// 1. A is invalidated (removed from cache) due to missing B
// 2. Both A and B are rebuilt locally
if err != nil {
t.Fatalf("Build failed: %v", err)
}

t.Log("✅ Build succeeded")

// Verify all packages are now in local cache (rebuilt)
if _, exists := localCache.Location(pkgX); !exists {
t.Error("Package X should be in local cache after build")
}
if _, exists := localCache.Location(pkgA); !exists {
t.Error("Package A should be in local cache after build")
}
if _, exists := localCache.Location(pkgB); !exists {
t.Error("Package B should be in local cache after build")
}

t.Log("✅ All packages are in local cache after build")
t.Log("✅ Dependency validation correctly handled missing dependency scenario")
}
Loading
Loading