Skip to content

Commit a19656c

Browse files
leodidoona-agent
andcommitted
fix: validate dependencies after download to prevent cache inconsistency
When a package is downloaded but one of its dependencies fails to download (e.g., network error, SLSA verification failure), the cached package becomes unusable because its dependencies are missing. This causes build failures that cannot recover because: 1. Package A is in cache, so A.build() returns early 2. A.buildDependencies() is never called 3. Missing dependency B is never built 4. Build fails with PkgNotBuiltErr{B} Fix: After the download phase, validate that all cached packages have their required dependencies available. If a dependency is missing and won't be built, remove the package from cache and mark it for rebuild. The validation respects package types: - Go/Yarn packages: check all transitive dependencies - Generic/Docker packages: check only direct dependencies - Ephemeral dependencies are skipped (always rebuilt) A dependency is considered available if: - It's in the local cache, OR - It will be built (PackageNotBuiltYet), OR - It will be downloaded (PackageInRemoteCache) Co-authored-by: Ona <no-reply@ona.com>
1 parent 1ab62ee commit a19656c

File tree

2 files changed

+328
-0
lines changed

2 files changed

+328
-0
lines changed

pkg/leeway/build.go

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -707,6 +707,31 @@ func Build(pkg *Package, opts ...BuildOption) (err error) {
707707
}
708708
}
709709

710+
// Validate that cached packages have all their dependencies available.
711+
// This prevents build failures when a package is cached but a dependency failed to download.
712+
// If a cached package has missing dependencies, remove it from cache and mark for rebuild.
713+
for _, p := range allpkg {
714+
status := pkgstatus[p]
715+
if status != PackageDownloaded && status != PackageBuilt {
716+
// Only validate packages that are in the local cache
717+
continue
718+
}
719+
720+
if !validateDependenciesAvailable(p, ctx.LocalCache, pkgstatus) {
721+
log.WithField("package", p.FullName()).Warn("Cached package has missing dependencies, will rebuild")
722+
723+
// Remove the package from local cache
724+
if path, exists := ctx.LocalCache.Location(p); exists {
725+
if err := os.Remove(path); err != nil {
726+
log.WithError(err).WithField("package", p.FullName()).Warn("Failed to remove package from cache")
727+
}
728+
}
729+
730+
// Mark for rebuild
731+
pkgstatus[p] = PackageNotBuiltYet
732+
}
733+
}
734+
710735
ctx.Reporter.BuildStarted(pkg, pkgstatus)
711736
defer func(err *error) {
712737
ctx.Reporter.BuildFinished(pkg, *err)
@@ -1303,6 +1328,58 @@ func (p *Package) packagesToDownload(inLocalCache map[*Package]struct{}, inRemot
13031328
}
13041329
}
13051330

1331+
// validateDependenciesAvailable checks if all required dependencies of a package are available.
1332+
// A dependency is considered available if it's in the local cache OR will be built (PackageNotBuiltYet).
1333+
// Returns true if all dependencies are available, false otherwise.
1334+
//
1335+
// This validation ensures cache consistency: a package should only remain in cache
1336+
// if all its dependencies are also available. This prevents build failures when
1337+
// a package is cached but one of its dependencies failed to download.
1338+
func validateDependenciesAvailable(p *Package, localCache cache.LocalCache, pkgstatus map[*Package]PackageBuildStatus) bool {
1339+
var deps []*Package
1340+
switch p.Type {
1341+
case YarnPackage, GoPackage:
1342+
// Go and Yarn packages need all transitive dependencies
1343+
deps = p.GetTransitiveDependencies()
1344+
case GenericPackage, DockerPackage:
1345+
// Generic and Docker packages only need direct dependencies
1346+
deps = p.GetDependencies()
1347+
default:
1348+
deps = p.GetDependencies()
1349+
}
1350+
1351+
for _, dep := range deps {
1352+
if dep.Ephemeral {
1353+
// Ephemeral packages are always rebuilt, skip validation
1354+
continue
1355+
}
1356+
1357+
_, inCache := localCache.Location(dep)
1358+
status := pkgstatus[dep]
1359+
1360+
// Dependency is available if:
1361+
// 1. It's in the local cache (PackageBuilt or PackageDownloaded), OR
1362+
// 2. It will be built locally (PackageNotBuiltYet), OR
1363+
// 3. It will be downloaded (PackageInRemoteCache)
1364+
depAvailable := inCache ||
1365+
status == PackageNotBuiltYet ||
1366+
status == PackageInRemoteCache ||
1367+
status == PackageBuilt ||
1368+
status == PackageDownloaded
1369+
1370+
if !depAvailable {
1371+
log.WithFields(log.Fields{
1372+
"package": p.FullName(),
1373+
"dependency": dep.FullName(),
1374+
"depStatus": status,
1375+
"inCache": inCache,
1376+
}).Debug("Dependency not available for cached package")
1377+
return false
1378+
}
1379+
}
1380+
return true
1381+
}
1382+
13061383
type PackageBuildPhase string
13071384

13081385
const (
Lines changed: 251 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,251 @@
1+
package leeway
2+
3+
import (
4+
"testing"
5+
6+
"github.com/gitpod-io/leeway/pkg/leeway/cache"
7+
"github.com/stretchr/testify/require"
8+
)
9+
10+
// mockLocalCache implements cache.LocalCache for testing
11+
type mockLocalCache struct {
12+
packages map[string]string // fullName -> path
13+
}
14+
15+
func newMockLocalCache() *mockLocalCache {
16+
return &mockLocalCache{
17+
packages: make(map[string]string),
18+
}
19+
}
20+
21+
func (m *mockLocalCache) Location(pkg cache.Package) (string, bool) {
22+
path, exists := m.packages[pkg.FullName()]
23+
return path, exists
24+
}
25+
26+
func (m *mockLocalCache) addPackage(fullName, path string) {
27+
m.packages[fullName] = path
28+
}
29+
30+
// newTestPackage creates a test package with the given name and type
31+
func newTestPackage(name string, pkgType PackageType) *Package {
32+
pkg := &Package{
33+
fullNameOverride: name,
34+
dependencies: []*Package{},
35+
}
36+
pkg.PackageInternal.Type = pkgType
37+
return pkg
38+
}
39+
40+
func TestValidateDependenciesAvailable(t *testing.T) {
41+
tests := []struct {
42+
name string
43+
setupPackages func() (*Package, map[*Package]PackageBuildStatus, *mockLocalCache)
44+
expectedResult bool
45+
}{
46+
{
47+
name: "package with no dependencies",
48+
setupPackages: func() (*Package, map[*Package]PackageBuildStatus, *mockLocalCache) {
49+
pkg := newTestPackage("test:pkg", GenericPackage)
50+
pkgstatus := map[*Package]PackageBuildStatus{
51+
pkg: PackageDownloaded,
52+
}
53+
cache := newMockLocalCache()
54+
cache.addPackage("test:pkg", "/cache/test-pkg.tar.gz")
55+
return pkg, pkgstatus, cache
56+
},
57+
expectedResult: true,
58+
},
59+
{
60+
name: "package with all dependencies in cache",
61+
setupPackages: func() (*Package, map[*Package]PackageBuildStatus, *mockLocalCache) {
62+
depA := newTestPackage("test:dep-a", GenericPackage)
63+
pkg := newTestPackage("test:pkg", GenericPackage)
64+
pkg.dependencies = []*Package{depA}
65+
66+
pkgstatus := map[*Package]PackageBuildStatus{
67+
pkg: PackageDownloaded,
68+
depA: PackageBuilt,
69+
}
70+
cache := newMockLocalCache()
71+
cache.addPackage("test:pkg", "/cache/test-pkg.tar.gz")
72+
cache.addPackage("test:dep-a", "/cache/test-dep-a.tar.gz")
73+
return pkg, pkgstatus, cache
74+
},
75+
expectedResult: true,
76+
},
77+
{
78+
name: "package with dependency marked for build",
79+
setupPackages: func() (*Package, map[*Package]PackageBuildStatus, *mockLocalCache) {
80+
depA := newTestPackage("test:dep-a", GenericPackage)
81+
pkg := newTestPackage("test:pkg", GenericPackage)
82+
pkg.dependencies = []*Package{depA}
83+
84+
pkgstatus := map[*Package]PackageBuildStatus{
85+
pkg: PackageDownloaded,
86+
depA: PackageNotBuiltYet, // Will be built
87+
}
88+
cache := newMockLocalCache()
89+
cache.addPackage("test:pkg", "/cache/test-pkg.tar.gz")
90+
// depA is NOT in cache but will be built
91+
return pkg, pkgstatus, cache
92+
},
93+
expectedResult: true,
94+
},
95+
{
96+
name: "package with dependency in remote cache",
97+
setupPackages: func() (*Package, map[*Package]PackageBuildStatus, *mockLocalCache) {
98+
depA := newTestPackage("test:dep-a", GenericPackage)
99+
pkg := newTestPackage("test:pkg", GenericPackage)
100+
pkg.dependencies = []*Package{depA}
101+
102+
pkgstatus := map[*Package]PackageBuildStatus{
103+
pkg: PackageDownloaded,
104+
depA: PackageInRemoteCache, // Will be downloaded
105+
}
106+
cache := newMockLocalCache()
107+
cache.addPackage("test:pkg", "/cache/test-pkg.tar.gz")
108+
return pkg, pkgstatus, cache
109+
},
110+
expectedResult: true,
111+
},
112+
{
113+
name: "package with missing dependency - not in cache, unknown status",
114+
setupPackages: func() (*Package, map[*Package]PackageBuildStatus, *mockLocalCache) {
115+
depA := newTestPackage("test:dep-a", GenericPackage)
116+
pkg := newTestPackage("test:pkg", GenericPackage)
117+
pkg.dependencies = []*Package{depA}
118+
119+
pkgstatus := map[*Package]PackageBuildStatus{
120+
pkg: PackageDownloaded,
121+
// depA has no status entry
122+
}
123+
cache := newMockLocalCache()
124+
cache.addPackage("test:pkg", "/cache/test-pkg.tar.gz")
125+
// depA is NOT in cache
126+
return pkg, pkgstatus, cache
127+
},
128+
expectedResult: false,
129+
},
130+
{
131+
name: "package with ephemeral dependency - should be skipped",
132+
setupPackages: func() (*Package, map[*Package]PackageBuildStatus, *mockLocalCache) {
133+
depA := newTestPackage("test:dep-a", GenericPackage)
134+
depA.Ephemeral = true // Ephemeral packages are always rebuilt
135+
136+
pkg := newTestPackage("test:pkg", GenericPackage)
137+
pkg.dependencies = []*Package{depA}
138+
139+
pkgstatus := map[*Package]PackageBuildStatus{
140+
pkg: PackageDownloaded,
141+
// depA has no status - but it's ephemeral so should be skipped
142+
}
143+
cache := newMockLocalCache()
144+
cache.addPackage("test:pkg", "/cache/test-pkg.tar.gz")
145+
return pkg, pkgstatus, cache
146+
},
147+
expectedResult: true,
148+
},
149+
{
150+
name: "Go package with transitive dependency missing",
151+
setupPackages: func() (*Package, map[*Package]PackageBuildStatus, *mockLocalCache) {
152+
depB := newTestPackage("test:dep-b", GenericPackage)
153+
depA := newTestPackage("test:dep-a", GenericPackage)
154+
depA.dependencies = []*Package{depB}
155+
156+
pkg := newTestPackage("test:pkg", GoPackage) // Go packages need transitive deps
157+
pkg.dependencies = []*Package{depA}
158+
159+
pkgstatus := map[*Package]PackageBuildStatus{
160+
pkg: PackageDownloaded,
161+
depA: PackageBuilt,
162+
// depB has no status - missing transitive dependency
163+
}
164+
cache := newMockLocalCache()
165+
cache.addPackage("test:pkg", "/cache/test-pkg.tar.gz")
166+
cache.addPackage("test:dep-a", "/cache/test-dep-a.tar.gz")
167+
// depB is NOT in cache
168+
return pkg, pkgstatus, cache
169+
},
170+
expectedResult: false,
171+
},
172+
{
173+
name: "Go package with all transitive dependencies available",
174+
setupPackages: func() (*Package, map[*Package]PackageBuildStatus, *mockLocalCache) {
175+
depB := newTestPackage("test:dep-b", GenericPackage)
176+
depA := newTestPackage("test:dep-a", GenericPackage)
177+
depA.dependencies = []*Package{depB}
178+
179+
pkg := newTestPackage("test:pkg", GoPackage)
180+
pkg.dependencies = []*Package{depA}
181+
182+
pkgstatus := map[*Package]PackageBuildStatus{
183+
pkg: PackageDownloaded,
184+
depA: PackageBuilt,
185+
depB: PackageBuilt,
186+
}
187+
cache := newMockLocalCache()
188+
cache.addPackage("test:pkg", "/cache/test-pkg.tar.gz")
189+
cache.addPackage("test:dep-a", "/cache/test-dep-a.tar.gz")
190+
cache.addPackage("test:dep-b", "/cache/test-dep-b.tar.gz")
191+
return pkg, pkgstatus, cache
192+
},
193+
expectedResult: true,
194+
},
195+
{
196+
name: "Docker package only checks direct dependencies",
197+
setupPackages: func() (*Package, map[*Package]PackageBuildStatus, *mockLocalCache) {
198+
depB := newTestPackage("test:dep-b", GenericPackage)
199+
depA := newTestPackage("test:dep-a", GenericPackage)
200+
depA.dependencies = []*Package{depB}
201+
202+
pkg := newTestPackage("test:pkg", DockerPackage) // Docker only needs direct deps
203+
pkg.dependencies = []*Package{depA}
204+
205+
pkgstatus := map[*Package]PackageBuildStatus{
206+
pkg: PackageDownloaded,
207+
depA: PackageBuilt,
208+
// depB has no status - but Docker doesn't need it
209+
}
210+
cache := newMockLocalCache()
211+
cache.addPackage("test:pkg", "/cache/test-pkg.tar.gz")
212+
cache.addPackage("test:dep-a", "/cache/test-dep-a.tar.gz")
213+
// depB is NOT in cache - but Docker doesn't check transitive deps
214+
return pkg, pkgstatus, cache
215+
},
216+
expectedResult: true,
217+
},
218+
}
219+
220+
for _, tt := range tests {
221+
t.Run(tt.name, func(t *testing.T) {
222+
pkg, pkgstatus, cache := tt.setupPackages()
223+
result := validateDependenciesAvailable(pkg, cache, pkgstatus)
224+
require.Equal(t, tt.expectedResult, result)
225+
})
226+
}
227+
}
228+
229+
func TestValidateDependenciesAvailable_YarnPackage(t *testing.T) {
230+
// Yarn packages should check transitive dependencies like Go packages
231+
depB := newTestPackage("test:dep-b", GenericPackage)
232+
depA := newTestPackage("test:dep-a", GenericPackage)
233+
depA.dependencies = []*Package{depB}
234+
235+
pkg := newTestPackage("test:pkg", YarnPackage)
236+
pkg.dependencies = []*Package{depA}
237+
238+
pkgstatus := map[*Package]PackageBuildStatus{
239+
pkg: PackageDownloaded,
240+
depA: PackageBuilt,
241+
// depB missing
242+
}
243+
244+
cache := newMockLocalCache()
245+
cache.addPackage("test:pkg", "/cache/test-pkg.tar.gz")
246+
cache.addPackage("test:dep-a", "/cache/test-dep-a.tar.gz")
247+
248+
// Should fail because Yarn needs transitive deps
249+
result := validateDependenciesAvailable(pkg, cache, pkgstatus)
250+
require.False(t, result, "Yarn package should fail validation when transitive dependency is missing")
251+
}

0 commit comments

Comments
 (0)