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 }