github.com/benchkram/bob@v0.0.0-20240314204020-b7a57f2f9be9/pkg/nix/nix.go (about)

     1  package nix
     2  
     3  import (
     4  	"bytes"
     5  	"errors"
     6  	"fmt"
     7  	"os"
     8  	"os/exec"
     9  	"runtime"
    10  	"strings"
    11  	"time"
    12  
    13  	"github.com/benchkram/bob/bob/global"
    14  	"github.com/benchkram/bob/pkg/boblog"
    15  	"github.com/benchkram/bob/pkg/filehash"
    16  	"github.com/benchkram/bob/pkg/format"
    17  	"github.com/benchkram/bob/pkg/usererror"
    18  	"github.com/benchkram/errz"
    19  )
    20  
    21  type Dependency struct {
    22  	// Name of the dependency
    23  	Name string
    24  	// Nixpkgs can be empty or a link to desired revision
    25  	// ex. https://github.com/NixOS/nixpkgs/archive/eeefd01d4f630fcbab6588fe3e7fffe0690fbb20.tar.gz
    26  	Nixpkgs string
    27  }
    28  
    29  // IsInstalled checks if nix is installed on the system
    30  func IsInstalled() bool {
    31  	_, err := exec.LookPath("nix")
    32  	return err == nil
    33  }
    34  
    35  // BuildDependencies build nix dependencies and returns a <package>-<nix store path> map
    36  //
    37  // dependencies can be either a package name ex. php or a path to .nix file
    38  // nixpkgs can be empty which means it will use local nixpkgs channel
    39  // or a link to desired revision ex. https://github.com/NixOS/nixpkgs/archive/eeefd01d4f630fcbab6588fe3e7fffe0690fbb20.tar.gz
    40  func BuildDependencies(deps []Dependency, cache *Cache) (err error) {
    41  	defer errz.Recover(&err)
    42  
    43  	var unsatisfiedDeps []Dependency
    44  
    45  	for _, v := range deps {
    46  		if cache != nil {
    47  			key, err := GenerateKey(v)
    48  			errz.Fatal(err)
    49  
    50  			if _, ok := cache.Get(key); ok {
    51  				continue
    52  			}
    53  			unsatisfiedDeps = append(unsatisfiedDeps, v)
    54  		} else {
    55  			unsatisfiedDeps = append(unsatisfiedDeps, v)
    56  		}
    57  	}
    58  
    59  	if len(unsatisfiedDeps) > 0 {
    60  		fmt.Println("Building nix dependencies. This may take a while...")
    61  	}
    62  
    63  	var max int
    64  	for _, v := range unsatisfiedDeps {
    65  		if len(v.Name) > max {
    66  			max = len(v.Name)
    67  		}
    68  	}
    69  	max += 1
    70  
    71  	for _, v := range unsatisfiedDeps {
    72  		var br buildResult
    73  		padding := strings.Repeat(" ", max-len(v.Name))
    74  
    75  		if strings.HasSuffix(v.Name, ".nix") {
    76  			br, err = buildFile(v.Name, v.Nixpkgs, padding)
    77  			if err != nil {
    78  				return err
    79  			}
    80  		} else {
    81  			br, err = buildPackage(v.Name, v.Nixpkgs, padding)
    82  			if err != nil {
    83  				return err
    84  			}
    85  		}
    86  
    87  		fmt.Println()
    88  		fmt.Printf("%s:%s%s took %s\n", v.Name, padding, br.storePath, format.DisplayDuration(br.duration))
    89  
    90  		if cache != nil {
    91  			key, err := GenerateKey(v)
    92  			errz.Fatal(err)
    93  
    94  			err = cache.Save(key, br.storePath)
    95  			errz.Fatal(err)
    96  		}
    97  	}
    98  	if len(unsatisfiedDeps) > 0 {
    99  		fmt.Println("Succeeded building nix dependencies")
   100  	}
   101  
   102  	return nil
   103  }
   104  
   105  type buildResult struct {
   106  	storePath string
   107  	duration  time.Duration
   108  }
   109  
   110  // buildPackage builds a nix package: nix-build --no-out-link -E 'with import <nixpkgs> { }; pkg' and returns the store path
   111  func buildPackage(pkgName string, nixpkgs, padding string) (buildResult, error) {
   112  	nixExpression := fmt.Sprintf("with import %s { }; [%s]", source(nixpkgs), pkgName)
   113  	args := []string{"--no-out-link", "-E"}
   114  	args = append(args, nixExpression)
   115  	cmd := exec.Command("nix-build", args...)
   116  	boblog.Log.V(5).Info(fmt.Sprintf("Executing command:\n  %s", cmd.String()))
   117  
   118  	progress := newBuildProgress(pkgName, padding)
   119  	progress.Start(5 * time.Second)
   120  
   121  	var stdoutBuf bytes.Buffer
   122  	cmd.Stdout = &stdoutBuf
   123  
   124  	err := cmd.Run()
   125  	if err != nil {
   126  		progress.Stop()
   127  		return buildResult{}, usererror.Wrap(errors.New("could not build package"))
   128  	}
   129  
   130  	for _, v := range strings.Split(stdoutBuf.String(), "\n") {
   131  		if strings.HasPrefix(v, "/nix/store/") {
   132  			progress.Stop()
   133  			return buildResult{
   134  				storePath: v,
   135  				duration:  progress.Duration(),
   136  			}, nil
   137  		}
   138  	}
   139  
   140  	return buildResult{}, nil
   141  }
   142  
   143  // buildFile builds a .nix expression file
   144  // `nix-build --no-out-link -E 'with import <nixpkgs> { }; callPackage filepath.nix {}'`
   145  func buildFile(filePath string, nixpkgs, padding string) (buildResult, error) {
   146  	nixExpression := fmt.Sprintf(`with import %s { }; callPackage %s {}`, source(nixpkgs), filePath)
   147  	args := []string{"--no-out-link"}
   148  	args = append(args, "--expr", nixExpression)
   149  	cmd := exec.Command("nix-build", args...)
   150  	boblog.Log.V(5).Info(fmt.Sprintf("Executing command:\n  %s", cmd.String()))
   151  
   152  	progress := newBuildProgress(filePath, padding)
   153  	progress.Start(5 * time.Second)
   154  
   155  	var stdoutBuf bytes.Buffer
   156  	var stderrBuf bytes.Buffer
   157  	cmd.Stdout = &stdoutBuf
   158  	cmd.Stderr = &stderrBuf
   159  
   160  	err := cmd.Run()
   161  	if err != nil {
   162  		progress.Stop()
   163  		return buildResult{}, usererror.Wrap(fmt.Errorf("could not build file `%s`, %w\n, %s\n, %s", filePath, err, stdoutBuf.String(), stderrBuf.String()))
   164  	}
   165  
   166  	for _, v := range strings.Split(stdoutBuf.String(), "\n") {
   167  		progress.Stop()
   168  		if strings.HasPrefix(v, "/nix/store/") {
   169  			return buildResult{
   170  				storePath: v,
   171  				duration:  progress.Duration(),
   172  			}, nil
   173  		}
   174  	}
   175  
   176  	return buildResult{}, nil
   177  }
   178  
   179  // DownloadURl give nix download URL based on OS
   180  func DownloadURl() string {
   181  	url := "https://nixos.org/download.html"
   182  
   183  	switch runtime.GOOS {
   184  	case "windows":
   185  		url = "https://nixos.org/download.html#nix-install-windows"
   186  	case "darwin":
   187  		url = "https://nixos.org/download.html#nix-install-macos"
   188  	case "linux":
   189  		url = "https://nixos.org/download.html#nix-install-linux"
   190  	}
   191  
   192  	return url
   193  }
   194  
   195  // AddDir add the dir path to .nix files specified in dependencies
   196  func AddDir(dir string, dependencies []string) []string {
   197  	for k, v := range dependencies {
   198  		if strings.HasSuffix(v, ".nix") {
   199  			dependencies[k] = dir + "/" + v
   200  		}
   201  	}
   202  	return dependencies
   203  }
   204  
   205  // Source of nixpkgs from where dependencies are built. If empty will use local <nixpkgs>
   206  // or a specific tarball can be used ex. https://github.com/NixOS/nixpkgs/archive/eeefd01d4f630fcbab6588fe3e7fffe0690fbb20.tar.gz
   207  func source(nixpkgs string) string {
   208  	if nixpkgs != "" {
   209  		return fmt.Sprintf("(fetchTarball \"%s\")", nixpkgs)
   210  	}
   211  	return "<nixpkgs>"
   212  }
   213  
   214  // BuildEnvironment is running nix-shell for a list of dependencies and fetch its whole environment
   215  //
   216  // nix-shell --pure --keep NIX_SSL_CERT_FILE --keep SSL_CERT_FILE -p --command 'env' -E nixExpressionFromDeps
   217  //
   218  // nix shell can be started with empty list of packages so this method works with empty deps as well
   219  func BuildEnvironment(deps []Dependency, nixpkgs string, cache *Cache, shellCache *ShellCache) (_ []string, err error) {
   220  	defer errz.Recover(&err)
   221  
   222  	// building dependencies with nix-build to display store paths to output
   223  	err = BuildDependencies(deps, cache)
   224  	errz.Fatal(err)
   225  
   226  	expression := nixExpression(deps, nixpkgs)
   227  
   228  	var arguments []string
   229  	for _, envKey := range global.EnvWhitelist {
   230  		if _, exists := os.LookupEnv(envKey); exists {
   231  			arguments = append(arguments, []string{"--keep", envKey}...)
   232  		}
   233  	}
   234  	arguments = append(arguments, []string{"--command", "env"}...)
   235  	arguments = append(arguments, []string{"--expr", expression}...)
   236  
   237  	cmd := exec.Command("nix-shell", "--pure")
   238  	cmd.Args = append(cmd.Args, arguments...)
   239  
   240  	var out bytes.Buffer
   241  	var errBuf bytes.Buffer
   242  	cmd.Stdout = &out
   243  	cmd.Stderr = &errBuf
   244  
   245  	if shellCache != nil {
   246  		key, err := shellCache.GenerateKey(deps, cmd.String())
   247  		errz.Fatal(err)
   248  
   249  		if dat, ok := shellCache.Get(key); ok {
   250  			out.Write(dat)
   251  		} else {
   252  			err = cmd.Run()
   253  			if err != nil {
   254  				return nil, prepareRunError(err, cmd.String(), errBuf)
   255  			}
   256  
   257  			err = shellCache.Save(key, out.Bytes())
   258  			errz.Fatal(err)
   259  		}
   260  	} else {
   261  		err = cmd.Run()
   262  		if err != nil {
   263  			return nil, prepareRunError(err, cmd.String(), errBuf)
   264  		}
   265  	}
   266  
   267  	env := strings.Split(out.String(), "\n")
   268  
   269  	// if NIX_SSL_CERT_FILE && SSL_CERT_FILE are set to /no-cert-file.crt unset them
   270  	var clearedEnv []string
   271  	for _, e := range env {
   272  		pair := strings.SplitN(e, "=", 2)
   273  		if pair[0] == "NIX_SSL_CERT_FILE" && pair[1] == "/no-cert-file.crt" {
   274  			continue
   275  		}
   276  		if pair[0] == "SSL_CERT_FILE" && pair[1] == "/no-cert-file.crt" {
   277  			continue
   278  		}
   279  		clearedEnv = append(clearedEnv, e)
   280  	}
   281  
   282  	return clearedEnv, nil
   283  }
   284  
   285  func prepareRunError(err error, cmd string, stderrBuf bytes.Buffer) error {
   286  	return usererror.Wrap(fmt.Errorf("could not run nix-shell command:\n %s\n%w\n%s", cmd, err, stderrBuf.String()))
   287  }
   288  
   289  // nixExpression computes the Nix expression which is passed to nix-shell via -E flag
   290  // Example of a Nix expression containing go_1_18 and a custom oapicodegen_v1.6.0.nix file:
   291  // { pkgs ? import <nixpkgs> {} }:
   292  //
   293  //	pkgs.mkShell {
   294  //	 buildInputs = [
   295  //	    pkgs.go_1_18
   296  //	    (pkgs.callPackage ./oapicodegen_v1.6.0.nix { } )
   297  //	 ];
   298  //	}
   299  func nixExpression(deps []Dependency, nixpkgs string) string {
   300  	var buildInputs []string
   301  	for _, v := range deps {
   302  		if strings.HasSuffix(v.Name, ".nix") {
   303  			buildInputs = append(buildInputs, fmt.Sprintf("(pkgs.callPackage %s{ } )", v.Name))
   304  		} else {
   305  			buildInputs = append(buildInputs, "pkgs."+v.Name)
   306  		}
   307  	}
   308  
   309  	exp := `
   310  { pkgs ? import %s {} }:
   311  pkgs.mkShell {
   312    buildInputs = [
   313  	 %s
   314    ];
   315  }
   316  `
   317  	return fmt.Sprintf(exp, source(nixpkgs), strings.Join(buildInputs, "\n"))
   318  }
   319  
   320  func HashDependencies(deps []Dependency) (_ string, err error) {
   321  	defer errz.Recover(&err)
   322  
   323  	h := filehash.New()
   324  	for _, dependency := range deps {
   325  		if strings.HasSuffix(dependency.Name, ".nix") {
   326  			err = h.AddBytes(bytes.NewBufferString(dependency.Nixpkgs))
   327  			errz.Fatal(err)
   328  
   329  			err = h.AddFile(dependency.Name)
   330  			errz.Fatal(err)
   331  		} else {
   332  			toHash := fmt.Sprintf("%s:%s", dependency.Name, dependency.Nixpkgs)
   333  			err = h.AddBytes(bytes.NewBufferString(toHash))
   334  			errz.Fatal(err)
   335  		}
   336  	}
   337  	return string(h.Sum()), nil
   338  }