Skip to content

Commit

Permalink
nix: make System, Version, SourceProfile public
Browse files Browse the repository at this point in the history
As part of some cleanup for making the DetSys installer the default,
move the code related to getting the Nix version and system to the
non-internal go.jetpack.io/devbox/nix package. It also merges in some
nearly-duplicate code from the search indexer's Nix code (which will
eventually use this package instead).

Some of the changes taken from the indexer are:

- Calling any function or method automatically...
	- sources the Nix profile if necessary.
	- looks for Nix in some well-known places if it isn't in PATH.
- `nix.Version` and `nix.System` are cached behind a `sync.Once` by
  default.

All top-level functions map to a method on a default Nix struct,
following the same pattern found in `flags`, `slog`, etc.

	const Version2_12 = "2.12.0" ...
	var Default = &Nix{}
	func AtLeast(version string) bool
	func SourceProfile() (sourced bool, err error)
	func System() string
	func Version() string
	type Info struct{ ... }
	    func (i Info) AtLeast(version string) bool
	type Nix struct{ ... }
	    func (n *Nix) Info() (Info, error)
	    func (n *Nix) System() string
	    func (n *Nix) Version() string
  • Loading branch information
gcurtis committed Dec 11, 2024
1 parent 892add7 commit 921ba51
Show file tree
Hide file tree
Showing 18 changed files with 721 additions and 580 deletions.
2 changes: 1 addition & 1 deletion internal/devbox/devbox.go
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@ func Open(opts *devopt.Opts) (*Devbox, error) {
cfg: cfg,
env: opts.Env,
environment: environment,
nix: &nix.Nix{},
nix: &nix.NixInstance{},
projectDir: filepath.Dir(cfg.Root.AbsRootPath),
pluginManager: plugin.NewManager(),
stderr: opts.Stderr,
Expand Down
21 changes: 3 additions & 18 deletions internal/devpkg/narinfo_cache.go
Original file line number Diff line number Diff line change
Expand Up @@ -73,14 +73,6 @@ func FillNarInfoCache(ctx context.Context, packages ...*Package) error {
return nil
}

// Pre-compute values read in fillNarInfoCache
// so they can be read from multiple go-routines without locks
_, err := nix.Version()
if err != nil {
return err
}
_ = nix.System()

group, _ := errgroup.WithContext(ctx)
for _, p := range eligiblePackages {
pkg := p // copy the loop variable since its used in a closure below
Expand Down Expand Up @@ -240,29 +232,22 @@ func (p *Package) sysInfoIfExists() (*lock.SystemInfo, error) {
return nil, nil
}

version, err := nix.Version()
if err != nil {
return nil, err
}

// disable for nix < 2.17
if !version.AtLeast(nix.Version2_17) {
return nil, err
if !nix.AtLeast(nix.Version2_17) {
return nil, nil
}

entry, err := p.lockfile.Resolve(p.Raw)
if err != nil {
return nil, err
}

userSystem := nix.System()

if entry.Systems == nil {
return nil, nil
}

// Check if the user's system's info is present in the lockfile
sysInfo, ok := entry.Systems[userSystem]
sysInfo, ok := entry.Systems[nix.System()]
if !ok {
return nil, nil
}
Expand Down
2 changes: 1 addition & 1 deletion internal/devpkg/package.go
Original file line number Diff line number Diff line change
Expand Up @@ -390,7 +390,7 @@ func (p *Package) normalizePackageAttributePath() (string, error) {

// We prefer nix.Search over just trying to parse the package's "URL" because
// nix.Search will guarantee that the package exists for the current system.
var infos map[string]*nix.Info
var infos map[string]*nix.PkgInfo
if p.IsDevboxPackage && !p.IsRunX() {
// Perf optimization: For queries of the form nixpkgs/<commit>#foo, we can
// use a nix.Search cache.
Expand Down
3 changes: 2 additions & 1 deletion internal/nix/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import (
"strings"

"go.jetpack.io/devbox/internal/redact"
"go.jetpack.io/devbox/nix"
)

// Config is a parsed Nix configuration.
Expand Down Expand Up @@ -106,7 +107,7 @@ func (c Config) IsUserTrusted(ctx context.Context, username string) (bool, error
}

func IncludeDevboxConfig(ctx context.Context, username string) error {
info, _ := versionInfo()
info, _ := nix.Default.Info()
path := cmp.Or(info.SystemConfig, "/etc/nix/nix.conf")
includePath := filepath.Join(filepath.Dir(path), "devbox-nix.conf")
b := fmt.Appendf(nil, "# This config was auto-generated by Devbox.\n\nextra-trusted-users = %s\n", username)
Expand Down
20 changes: 5 additions & 15 deletions internal/nix/install.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,8 @@ import (
"go.jetpack.io/devbox/internal/build"
"go.jetpack.io/devbox/internal/cmdutil"
"go.jetpack.io/devbox/internal/fileutil"
"go.jetpack.io/devbox/internal/redact"
"go.jetpack.io/devbox/internal/ux"
"go.jetpack.io/devbox/nix"
)

const rootError = "warning: installing Nix as root is not supported by this script!"
Expand Down Expand Up @@ -121,33 +121,23 @@ func EnsureNixInstalled(writer io.Writer, withDaemonFunc func() *bool) (err erro
return
}

var version VersionInfo
version, err = Version()
if err != nil {
err = redact.Errorf("nix: ensure install: get version: %w", err)
return
}

// ensure minimum nix version installed
if !version.AtLeast(MinVersion) {
if !nix.AtLeast(MinVersion) {
err = usererr.New(
"Devbox requires nix of version >= %s. Your version is %s. "+
"Please upgrade nix and try again.\n",
MinVersion,
version,
nix.Version(),
)
return
}
// call ComputeSystem to ensure its value is internally cached so other
// callers can rely on just calling System
err = ComputeSystem()
}()

if BinaryInstalled() {
return nil
}
if dirExists() {
if err = SourceNixEnv(); err != nil {
if _, err = SourceProfile(); err != nil {
return err
} else if BinaryInstalled() {
return nil
Expand All @@ -174,7 +164,7 @@ func EnsureNixInstalled(writer io.Writer, withDaemonFunc func() *bool) (err erro
}

// Source again
if err = SourceNixEnv(); err != nil {
if _, err = SourceProfile(); err != nil {
return err
}

Expand Down
2 changes: 1 addition & 1 deletion internal/nix/instance.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ package nix
import "context"

// These make it easier to stub out nix for testing
type Nix struct{}
type NixInstance struct{}

type Nixer interface {
PrintDevEnv(ctx context.Context, args *PrintDevEnvArgs) (*PrintDevEnvOut, error)
Expand Down
220 changes: 1 addition & 219 deletions internal/nix/nix.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,15 +16,13 @@ import (
"runtime"
"runtime/trace"
"strings"
"sync"
"time"

"github.com/pkg/errors"
"go.jetpack.io/devbox/internal/boxcli/featureflag"
"go.jetpack.io/devbox/internal/boxcli/usererr"
"go.jetpack.io/devbox/internal/redact"
"go.jetpack.io/devbox/nix/flake"
"golang.org/x/mod/semver"

"go.jetpack.io/devbox/internal/debug"
)
Expand All @@ -51,7 +49,7 @@ type PrintDevEnvArgs struct {

// PrintDevEnv calls `nix print-dev-env -f <path>` and returns its output. The output contains
// all the environment variables and bash functions required to create a nix shell.
func (*Nix) PrintDevEnv(ctx context.Context, args *PrintDevEnvArgs) (*PrintDevEnvOut, error) {
func (*NixInstance) PrintDevEnv(ctx context.Context, args *PrintDevEnvArgs) (*PrintDevEnvOut, error) {
defer debug.FunctionTimer().End()
defer trace.StartRegion(ctx, "nixPrintDevEnv").End()

Expand Down Expand Up @@ -128,226 +126,10 @@ func ExperimentalFlags() []string {
}
}

func System() string {
if cachedSystem == "" {
// While this should have been initialized, we do a best-effort to avoid
// a panic.
if err := ComputeSystem(); err != nil {
panic(fmt.Sprintf(
"System called before being initialized by ComputeSystem: %v",
err,
))
}
}
return cachedSystem
}

var cachedSystem string

func ComputeSystem() error {
// For Savil to debug "remove nixpkgs" feature. The Search api lacks x86-darwin info.
// So, I need to fake that I am x86-linux and inspect the output in generated devbox.lock
// and flake.nix files.
// This is also used by unit tests.
if cachedSystem != "" {
return nil
}
override := os.Getenv("__DEVBOX_NIX_SYSTEM")
if override != "" {
cachedSystem = override
} else {
cmd := command("eval", "--impure", "--raw", "--expr", "builtins.currentSystem")
out, err := cmd.Output(context.TODO())
if err != nil {
return err
}
cachedSystem = string(out)
}
return nil
}

func SystemIsLinux() bool {
return strings.Contains(System(), "linux")
}

// All major Nix versions supported by Devbox.
const (
Version2_12 = "2.12.0"
Version2_13 = "2.13.0"
Version2_14 = "2.14.0"
Version2_15 = "2.15.0"
Version2_16 = "2.16.0"
Version2_17 = "2.17.0"
Version2_18 = "2.18.0"
Version2_19 = "2.19.0"
Version2_20 = "2.20.0"
Version2_21 = "2.21.0"
Version2_22 = "2.22.0"

MinVersion = Version2_12
)

// versionRegexp matches the first line of "nix --version" output.
//
// The semantic component is sourced from <https://semver.org/#is-there-a-suggested-regular-expression-regex-to-check-a-semver-string>.
// It's been modified to tolerate Nix prerelease versions, which don't have a
// hyphen before the prerelease component and contain underscores.
var versionRegexp = regexp.MustCompile(`^(.+) \(.+\) ((?P<major>0|[1-9]\d*)\.(?P<minor>0|[1-9]\d*)\.(?P<patch>0|[1-9]\d*)(?:(?:-|pre)(?P<prerelease>(?:0|[1-9]\d*|\d*[_a-zA-Z-][_0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[_a-zA-Z-][_0-9a-zA-Z-]*))*))?(?:\+(?P<buildmetadata>[0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?)$`)

// preReleaseRegexp matches Nix prerelease version strings, which are not valid
// semvers.
var preReleaseRegexp = regexp.MustCompile(`pre(?P<date>[0-9]+)_(?P<commit>[a-f0-9]{4,40})$`)

// VersionInfo contains information about a Nix installation.
type VersionInfo struct {
// Name is the executed program name (the first element of argv).
Name string

// Version is the semantic Nix version string.
Version string

// System is the current Nix system. It follows the pattern <arch>-<os>
// and does not use the same values as GOOS or GOARCH.
System string

// ExtraSystems are other systems that the current machine supports.
// Usually set by the extra-platforms setting in nix.conf.
ExtraSystems []string

// Features are the capabilities that the Nix binary was compiled with.
Features []string

// SystemConfig is the path to the Nix system configuration file,
// usually /etc/nix/nix.conf.
SystemConfig string

// UserConfigs is a list of paths to the user's Nix configuration files.
UserConfigs []string

// StoreDir is the path to the Nix store directory, usually /nix/store.
StoreDir string

// StateDir is the path to the Nix state directory, usually
// /nix/var/nix.
StateDir string

// DataDir is the path to the Nix data directory, usually somewhere
// within the Nix store. This field is empty for Nix versions <= 2.12.
DataDir string
}

func parseVersionInfo(data []byte) (VersionInfo, error) {
// Example nix --version --debug output from Nix versions 2.12 to 2.21.
// Version 2.12 omits the data directory, but they're otherwise
// identical.
//
// See https://github.com/NixOS/nix/blob/5b9cb8b3722b85191ee8cce8f0993170e0fc234c/src/libmain/shared.cc#L284-L305
//
// nix (Nix) 2.21.2
// System type: aarch64-darwin
// Additional system types: x86_64-darwin
// Features: gc, signed-caches
// System configuration file: /etc/nix/nix.conf
// User configuration files: /Users/nobody/.config/nix/nix.conf:/etc/xdg/nix/nix.conf
// Store directory: /nix/store
// State directory: /nix/var/nix
// Data directory: /nix/store/m0ns07v8by0458yp6k30rfq1rs3kaz6g-nix-2.21.2/share

info := VersionInfo{}
if len(data) == 0 {
return info, redact.Errorf("empty nix --version output")
}

lines := strings.Split(string(data), "\n")
matches := versionRegexp.FindStringSubmatch(lines[0])
if len(matches) < 3 {
return info, redact.Errorf("parse nix version: %s", redact.Safe(lines[0]))
}
info.Name = matches[1]
info.Version = matches[2]
for _, line := range lines {
name, value, found := strings.Cut(line, ": ")
if !found {
continue
}

switch name {
case "System type":
info.System = value
case "Additional system types":
info.ExtraSystems = strings.Split(value, ", ")
case "Features":
info.Features = strings.Split(value, ", ")
case "System configuration file":
info.SystemConfig = value
case "User configuration files":
info.UserConfigs = strings.Split(value, ":")
case "Store directory":
info.StoreDir = value
case "State directory":
info.StateDir = value
case "Data directory":
info.DataDir = value
}
}
return info, nil
}

// AtLeast returns true if v.Version is >= version per semantic versioning. It
// always returns false if v.Version is empty or invalid, such as when the
// current Nix version cannot be parsed. It panics if version is an invalid
// semver.
func (v VersionInfo) AtLeast(version string) bool {
if !strings.HasPrefix(version, "v") {
version = "v" + version
}
if !semver.IsValid(version) {
panic(fmt.Sprintf("nix.atLeast: invalid version %q", version[1:]))
}
if semver.IsValid("v" + v.Version) {
return semver.Compare("v"+v.Version, version) >= 0
}

// If the version isn't a valid semver, check to see if it's a
// prerelease (e.g., 2.23.0pre20240526_7de033d6) and coerce it to a
// valid version (2.23.0-pre.20240526+7de033d6) so we can compare it.
prerelease := preReleaseRegexp.ReplaceAllString(v.Version, "-pre.$date+$commit")
return semver.Compare("v"+prerelease, version) >= 0
}

// version is the cached output of `nix --version --debug`.
var versionInfo = sync.OnceValues(runNixVersion)

func runNixVersion() (VersionInfo, error) {
// Arbitrary timeout to make sure we don't take too long or hang.
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()

// Intentionally don't use the nix.command function here. We use this to
// perform Nix version checks and don't want to pass any extra-features
// or flags that might be missing from old versions.
cmd := exec.CommandContext(ctx, "nix", "--version", "--debug")
out, err := cmd.Output()
if err != nil {
var exitErr *exec.ExitError
if errors.As(err, &exitErr) && len(exitErr.Stderr) != 0 {
return VersionInfo{}, redact.Errorf("nix command: %s: %q: %v", redact.Safe(cmd), exitErr.Stderr, err)
}
if errors.Is(ctx.Err(), context.DeadlineExceeded) {
return VersionInfo{}, redact.Errorf("nix command: %s: timed out while reading output: %v", redact.Safe(cmd), err)
}
return VersionInfo{}, redact.Errorf("nix command: %s: %v", redact.Safe(cmd), err)
}

slog.Debug("nix --version --debug output", "out", out)
return parseVersionInfo(out)
}

// Version returns the currently installed version of Nix.
func Version() (VersionInfo, error) {
return versionInfo()
}

var nixPlatforms = []string{
"aarch64-darwin",
"aarch64-linux",
Expand Down
Loading

0 comments on commit 921ba51

Please sign in to comment.