Skip to content

Commit ff28666

Browse files
authored
[rm nixpkgs] make HEAD request to BinaryCache to ensure binary is cached (#1318)
## Summary This PR makes a HTTP HEAD request to `https://cache.nixos.org/<hash>.narinfo` to verify that the binary is indeed cached. If not, we fallback to the legacy path that is dependent on downloading nixpkgs. **Implementation note** In `IsInBinaryCache`, I wanted to insert the HEAD request. However, this function can be called in a loop for all packages, which is bad for perf. Instead, in this design, a new function `FillNarInfoCache(packageList)` makes the HEAD requests concurrently, storing results in an in-memory cache. We _require_ that callers invoke `FillNarInfoCache(packageList)` prior to invoking `IsInBinaryCache`. Failing this, `IsInBinaryCache` will return an error. Not thrilled with this design, since it may lead to inadvertent errors, but this code is pretty core and our test coverage should expose any codepaths that call `IsInBinaryCache` without `FillNarInfoCache`. ## How was it tested? I could do `devbox shell` in a project that has `hello` package. I inserted some print statements to verify that the HEAD response was 200 and that the caching for the boolean value of isNarInfoInCache was working.
1 parent a5f0f13 commit ff28666

File tree

11 files changed

+298
-25
lines changed

11 files changed

+298
-25
lines changed

go.mod

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,13 +45,17 @@ require (
4545
gopkg.in/yaml.v3 v3.0.1
4646
)
4747

48-
require github.com/joho/godotenv v1.5.1
48+
require (
49+
github.com/joho/godotenv v1.5.1
50+
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f
51+
)
4952

5053
require (
5154
github.com/bahlo/generic-list-go v0.2.0 // indirect
5255
github.com/buger/jsonparser v1.1.1 // indirect
5356
github.com/kr/pretty v0.1.0 // indirect
5457
github.com/mailru/easyjson v0.7.7 // indirect
58+
golang.org/x/net v0.8.0 // indirect
5559
)
5660

5761
require (

go.sum

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -231,6 +231,8 @@ golang.org/x/mod v0.11.0 h1:bUO06HqtnRcc/7l71XBe4WcqTZ+3AH1J59zWDDwLKgU=
231231
golang.org/x/mod v0.11.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
232232
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
233233
golang.org/x/net v0.8.0 h1:Zrh2ngAOFYneWTAIAPethzeaQLuHwhuBkuV6ZiRnUaQ=
234+
golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc=
235+
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f h1:wMNYb4v58l5UBM7MYRLPG6ZhfOqbKu7X5eyFl8ZhKvA=
234236
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
235237
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
236238
golang.org/x/sys v0.0.0-20190204203706-41f3e6584952/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=

internal/devpkg/package.go

Lines changed: 203 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,19 @@
44
package devpkg
55

66
import (
7+
"context"
78
"crypto/md5"
89
"encoding/hex"
910
"fmt"
1011
"io"
12+
"net/http"
1113
"net/url"
1214
"path/filepath"
1315
"regexp"
1416
"strings"
17+
"sync"
18+
"time"
19+
"unicode"
1520

1621
"github.com/pkg/errors"
1722
"github.com/samber/lo"
@@ -23,6 +28,7 @@ import (
2328
"go.jetpack.io/devbox/internal/nix"
2429
"go.jetpack.io/devbox/internal/vercheck"
2530
"go.jetpack.io/devbox/plugins"
31+
"golang.org/x/sync/errgroup"
2632
)
2733

2834
// Package represents a "package" added to the devbox.json config.
@@ -53,6 +59,20 @@ type Package struct {
5359
normalizedPackageAttributePathCache string // memoized value from normalizedPackageAttributePath()
5460
}
5561

62+
// isNarInfoInCache checks if the .narinfo for this package is in the `BinaryCache`.
63+
// The key is the `Package.Raw` string.
64+
// This cannot be a field on the Package struct, because that struct
65+
// is constructed multiple times in a request (TODO: we could fix that).
66+
var isNarInfoInCache = struct {
67+
status map[string]bool
68+
lock sync.RWMutex
69+
// re-use httpClient to re-use the connection
70+
httpClient http.Client
71+
}{
72+
status: map[string]bool{},
73+
httpClient: http.Client{},
74+
}
75+
5676
// PackageFromStrings constructs Package from the list of package names provided.
5777
// These names correspond to devbox packages from the devbox.json config.
5878
func PackageFromStrings(rawNames []string, l lock.Locker) []*Package {
@@ -172,6 +192,7 @@ func (p *Package) IsInstallable() bool {
172192
// Installable for this package. Installable is a nix concept defined here:
173193
// https://nixos.org/manual/nix/stable/command-ref/new-cli/nix.html#installables
174194
func (p *Package) Installable() (string, error) {
195+
175196
inCache, err := p.IsInBinaryCache()
176197
if err != nil {
177198
return "", err
@@ -421,7 +442,23 @@ func (p *Package) LegacyToVersioned() string {
421442
return p.Raw + "@latest"
422443
}
423444

424-
func (p *Package) EnsureNixpkgsPrefetched(w io.Writer) error {
445+
// ensureNixpkgsPrefetched will prefetch flake for the nixpkgs registry for the package.
446+
// This is an internal method, and should not be called directly.
447+
func EnsureNixpkgsPrefetched(ctx context.Context, w io.Writer, pkgs []*Package) error {
448+
if err := FillNarInfoCache(ctx, pkgs...); err != nil {
449+
return err
450+
}
451+
for _, input := range pkgs {
452+
if err := input.ensureNixpkgsPrefetched(w); err != nil {
453+
return err
454+
}
455+
}
456+
return nil
457+
}
458+
459+
// ensureNixpkgsPrefetched should be called via the public EnsureNixpkgsPrefetched.
460+
// See function comment there.
461+
func (p *Package) ensureNixpkgsPrefetched(w io.Writer) error {
425462

426463
inCache, err := p.IsInBinaryCache()
427464
if err != nil {
@@ -462,37 +499,117 @@ func (p *Package) HashFromNixPkgsURL() string {
462499
// It is used as FromStore in builtins.fetchClosure.
463500
const BinaryCache = "https://cache.nixos.org"
464501

465-
func (p *Package) IsInBinaryCache() (bool, error) {
502+
func (p *Package) isEligibleForBinaryCache() (bool, error) {
503+
sysInfo, err := p.sysInfoIfExists()
504+
if err != nil {
505+
return false, err
506+
}
507+
return sysInfo != nil, nil
508+
}
509+
510+
// sysInfoIfExists returns the system info for the user's system. If the sysInfo
511+
// is missing, then nil is returned
512+
// NOTE: this is called from multiple go-routines and needs to be concurrency safe.
513+
// Hence, we compute nix.Version, nix.System and lockfile.Resolve prior to calling this
514+
// function from within a goroutine.
515+
func (p *Package) sysInfoIfExists() (*lock.SystemInfo, error) {
466516
if !featureflag.RemoveNixpkgs.Enabled() {
467-
return false, nil
517+
return nil, nil
468518
}
469519

470520
if !p.isVersioned() {
471-
return false, nil
521+
return nil, nil
522+
}
523+
524+
version, err := nix.Version()
525+
if err != nil {
526+
return nil, err
527+
}
528+
529+
// enable for nix >= 2.17
530+
if vercheck.SemverCompare(version, "2.17.0") < 0 {
531+
return nil, err
472532
}
473533

474534
entry, err := p.lockfile.Resolve(p.Raw)
475535
if err != nil {
476-
return false, err
536+
return nil, err
477537
}
478538

539+
userSystem := nix.System()
540+
479541
if entry.Systems == nil {
480-
return false, nil
542+
return nil, nil
481543
}
482544

483545
// Check if the user's system's info is present in the lockfile
484-
_, ok := entry.Systems[nix.System()]
546+
sysInfo, ok := entry.Systems[userSystem]
485547
if !ok {
548+
return nil, nil
549+
}
550+
return sysInfo, nil
551+
}
552+
553+
// IsInBinaryCache returns true if the package is in the binary cache.
554+
// ALERT: Callers must call FillNarInfoCache before calling this function.
555+
func (p *Package) IsInBinaryCache() (bool, error) {
556+
557+
if eligible, err := p.isEligibleForBinaryCache(); err != nil {
558+
return false, err
559+
} else if !eligible {
486560
return false, nil
487561
}
488562

489-
version, err := nix.Version()
563+
// Check if the narinfo is present in the binary cache
564+
isNarInfoInCache.lock.RLock()
565+
exists, ok := isNarInfoInCache.status[p.Raw]
566+
isNarInfoInCache.lock.RUnlock()
567+
if !ok {
568+
return false, errors.Errorf("narInfo cache miss: %v. call XYZ before invoking IsInBinaryCache", p.Raw)
569+
}
570+
return exists, nil
571+
}
572+
573+
// fillNarInfoCache fills the cache value for the narinfo of this package,
574+
// if it is eligible for the binary cache.
575+
// NOTE: this must be concurrency safe.
576+
func (p *Package) fillNarInfoCache() error {
577+
if eligible, err := p.isEligibleForBinaryCache(); err != nil {
578+
return err
579+
} else if !eligible {
580+
return nil
581+
}
582+
583+
sysInfo, err := p.sysInfoIfExists()
490584
if err != nil {
491-
return false, err
585+
return err
586+
} else if sysInfo == nil {
587+
return errors.New(
588+
"sysInfo is nil, but should not be because" +
589+
" the package is eligible for binary cache",
590+
)
492591
}
493592

494-
// enable for nix >= 2.17
495-
return vercheck.SemverCompare(version, "2.17.0") >= 0, nil
593+
pathParts := newStorePathParts(sysInfo.StorePath)
594+
reqURL := BinaryCache + "/" + pathParts.hash + ".narinfo"
595+
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
596+
defer cancel()
597+
req, err := http.NewRequestWithContext(ctx, http.MethodHead, reqURL, nil)
598+
if err != nil {
599+
return err
600+
}
601+
res, err := isNarInfoInCache.httpClient.Do(req)
602+
if err != nil {
603+
return err
604+
}
605+
// read the body fully, and close it to ensure the connection is reused.
606+
_, _ = io.Copy(io.Discard, res.Body)
607+
defer res.Body.Close()
608+
609+
isNarInfoInCache.lock.Lock()
610+
isNarInfoInCache.status[p.Raw] = res.StatusCode == 200
611+
isNarInfoInCache.lock.Unlock()
612+
return nil
496613
}
497614

498615
// InputAddressedPath is the input-addressed path in /nix/store
@@ -542,3 +659,78 @@ func (p *Package) EnsureUninstallableIsInLockfile() error {
542659
_, err := p.lockfile.Resolve(p.Raw)
543660
return err
544661
}
662+
663+
// storePath are the constituent parts of
664+
// /nix/store/<hash>-<name>-<version>
665+
//
666+
// This is a helper struct for analyzing the string representation
667+
type storePathParts struct {
668+
hash string
669+
name string
670+
version string
671+
}
672+
673+
// newStorePathParts splits a Nix store path into its hash, name and version
674+
// components in the same way that Nix does.
675+
//
676+
// See https://nixos.org/manual/nix/stable/language/builtins.html#builtins-parseDrvName
677+
func newStorePathParts(path string) storePathParts {
678+
path = strings.TrimPrefix(path, "/nix/store/")
679+
// path is now <hash>-<name>-<version
680+
681+
hash, name := path[:32], path[33:]
682+
dashIndex := 0
683+
for i, r := range name {
684+
if dashIndex != 0 && !unicode.IsLetter(r) {
685+
return storePathParts{hash: hash, name: name[:dashIndex], version: name[i:]}
686+
}
687+
dashIndex = 0
688+
if r == '-' {
689+
dashIndex = i
690+
}
691+
}
692+
return storePathParts{hash: hash, name: name}
693+
}
694+
695+
// FillNarInfoCache checks the remote binary cache for the narinfo of each
696+
// package in the list, and caches the result.
697+
// Callers of IsInBinaryCache must call this function first.
698+
func FillNarInfoCache(ctx context.Context, packages ...*Package) error {
699+
700+
// Pre-compute values read in fillNarInfoCache
701+
// so they can be read from multiple go-routines without locks
702+
_, err := nix.Version()
703+
if err != nil {
704+
return err
705+
}
706+
_ = nix.System()
707+
for _, p := range packages {
708+
_, err := p.lockfile.Resolve(p.Raw)
709+
if err != nil {
710+
return err
711+
}
712+
}
713+
714+
group, _ := errgroup.WithContext(ctx)
715+
for _, p := range packages {
716+
// If the package's NarInfo status is already known, skip it
717+
isNarInfoInCache.lock.RLock()
718+
_, ok := isNarInfoInCache.status[p.Raw]
719+
isNarInfoInCache.lock.RUnlock()
720+
if ok {
721+
continue
722+
}
723+
pkg := p // copy the loop variable since its used in a closure below
724+
group.Go(func() error {
725+
err := pkg.fillNarInfoCache()
726+
if err != nil {
727+
// default to false if there was an error, so we don't re-try
728+
isNarInfoInCache.lock.Lock()
729+
isNarInfoInCache.status[pkg.Raw] = false
730+
isNarInfoInCache.lock.Unlock()
731+
}
732+
return err
733+
})
734+
}
735+
return group.Wait()
736+
}

internal/devpkg/package_test.go

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,3 +183,46 @@ func TestHashFromNixPkgsURL(t *testing.T) {
183183
}
184184
}
185185
}
186+
187+
func TestStorePathParts(t *testing.T) {
188+
testCases := []struct {
189+
storePath string
190+
expected storePathParts
191+
}{
192+
// simple case:
193+
{
194+
storePath: "/nix/store/cvrn84c1hshv2wcds7n1rhydi6lacqns-gnumake-4.4.1",
195+
expected: storePathParts{
196+
hash: "cvrn84c1hshv2wcds7n1rhydi6lacqns",
197+
name: "gnumake",
198+
version: "4.4.1",
199+
},
200+
},
201+
// the package name can have dashes:
202+
{
203+
storePath: "/nix/store/q2xdxsswjqmqcbax81pmazm367s7jzyb-cctools-binutils-darwin-wrapper-973.0.1",
204+
expected: storePathParts{
205+
hash: "q2xdxsswjqmqcbax81pmazm367s7jzyb",
206+
name: "cctools-binutils-darwin-wrapper",
207+
version: "973.0.1",
208+
},
209+
},
210+
// version is optional. This is an artificial example I constructed
211+
{
212+
storePath: "/nix/store/gfxwrd5nggc68pjj3g3jhlldim9rpg0p-coreutils",
213+
expected: storePathParts{
214+
hash: "gfxwrd5nggc68pjj3g3jhlldim9rpg0p",
215+
name: "coreutils",
216+
},
217+
},
218+
}
219+
220+
for _, testCase := range testCases {
221+
t.Run(testCase.storePath, func(t *testing.T) {
222+
parts := newStorePathParts(testCase.storePath)
223+
if parts != testCase.expected {
224+
t.Errorf("Expected %v, got %v", testCase.expected, parts)
225+
}
226+
})
227+
}
228+
}

internal/impl/devbox.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -723,7 +723,7 @@ func (d *Devbox) StartProcessManager(
723723
processComposePath, err := utilityLookPath("process-compose")
724724
if err != nil {
725725
fmt.Fprintln(d.writer, "Installing process-compose. This may take a minute but will only happen once.")
726-
if err = d.addDevboxUtilityPackage("github:F1bonacc1/process-compose/v0.43.1"); err != nil {
726+
if err = d.addDevboxUtilityPackage(ctx, "github:F1bonacc1/process-compose/v0.43.1"); err != nil {
727727
return err
728728
}
729729

0 commit comments

Comments
 (0)