4
4
package devpkg
5
5
6
6
import (
7
+ "context"
7
8
"crypto/md5"
8
9
"encoding/hex"
9
10
"fmt"
10
11
"io"
12
+ "net/http"
11
13
"net/url"
12
14
"path/filepath"
13
15
"regexp"
14
16
"strings"
17
+ "sync"
18
+ "time"
19
+ "unicode"
15
20
16
21
"github.com/pkg/errors"
17
22
"github.com/samber/lo"
@@ -23,6 +28,7 @@ import (
23
28
"go.jetpack.io/devbox/internal/nix"
24
29
"go.jetpack.io/devbox/internal/vercheck"
25
30
"go.jetpack.io/devbox/plugins"
31
+ "golang.org/x/sync/errgroup"
26
32
)
27
33
28
34
// Package represents a "package" added to the devbox.json config.
@@ -53,6 +59,20 @@ type Package struct {
53
59
normalizedPackageAttributePathCache string // memoized value from normalizedPackageAttributePath()
54
60
}
55
61
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
+
56
76
// PackageFromStrings constructs Package from the list of package names provided.
57
77
// These names correspond to devbox packages from the devbox.json config.
58
78
func PackageFromStrings (rawNames []string , l lock.Locker ) []* Package {
@@ -172,6 +192,7 @@ func (p *Package) IsInstallable() bool {
172
192
// Installable for this package. Installable is a nix concept defined here:
173
193
// https://nixos.org/manual/nix/stable/command-ref/new-cli/nix.html#installables
174
194
func (p * Package ) Installable () (string , error ) {
195
+
175
196
inCache , err := p .IsInBinaryCache ()
176
197
if err != nil {
177
198
return "" , err
@@ -421,7 +442,23 @@ func (p *Package) LegacyToVersioned() string {
421
442
return p .Raw + "@latest"
422
443
}
423
444
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 {
425
462
426
463
inCache , err := p .IsInBinaryCache ()
427
464
if err != nil {
@@ -462,37 +499,117 @@ func (p *Package) HashFromNixPkgsURL() string {
462
499
// It is used as FromStore in builtins.fetchClosure.
463
500
const BinaryCache = "https://cache.nixos.org"
464
501
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 ) {
466
516
if ! featureflag .RemoveNixpkgs .Enabled () {
467
- return false , nil
517
+ return nil , nil
468
518
}
469
519
470
520
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
472
532
}
473
533
474
534
entry , err := p .lockfile .Resolve (p .Raw )
475
535
if err != nil {
476
- return false , err
536
+ return nil , err
477
537
}
478
538
539
+ userSystem := nix .System ()
540
+
479
541
if entry .Systems == nil {
480
- return false , nil
542
+ return nil , nil
481
543
}
482
544
483
545
// 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 ]
485
547
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 {
486
560
return false , nil
487
561
}
488
562
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 ()
490
584
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
+ )
492
591
}
493
592
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
496
613
}
497
614
498
615
// InputAddressedPath is the input-addressed path in /nix/store
@@ -542,3 +659,78 @@ func (p *Package) EnsureUninstallableIsInLockfile() error {
542
659
_ , err := p .lockfile .Resolve (p .Raw )
543
660
return err
544
661
}
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
+ }
0 commit comments