├── LICENSE ├── README.md ├── Setup.hs ├── app └── Main.hs ├── default.nix ├── nix-delegate.cabal ├── release.nix ├── shell.nix └── src └── Nix └── Delegate.hs /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2017 Awake Networks 2 | 3 | Licensed under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. 5 | You may obtain a copy of the License at 6 | 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | Unless required by applicable law or agreed to in writing, software 10 | distributed under the License is distributed on an "AS IS" BASIS, 11 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | See the License for the specific language governing permissions and 13 | limitations under the License. 14 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | **NOTE**: _This repository is no longer supported or updated by Awake Security / Arista Networks. If you wish to continue to develop this code yourself, we recommend you fork it._ 2 | 3 | # `nix-delegate` 4 | 5 | This is a command-line utility that you can use to run a subcommand with 6 | distributed builds transiently enabled: 7 | 8 | ```bash 9 | $ nix-delegate --help 10 | Usage: nix-delegate --host ARG ([--x86_64-linux] | [--x86_64-darwin]) 11 | [--key ARG] [--cores ARG] [--feature ARG] COMMAND [ARGS] 12 | Run a subcommand with distributed builds transiently enabled 13 | 14 | Available options: 15 | --host ARG Machine to use as a build slave 16 | --key ARG Path to SSH private key (Default: ~/.ssh/id_rsa) 17 | --cores ARG Number of cores to use (Default: 1) 18 | --feature ARG Supported system features 19 | COMMAND Command to delegate (if 'nix-build', sudo will be 20 | used if $NIX_REMOTE=daemon) 21 | -h,--help Show this help text 22 | ``` 23 | 24 | The first positional argument is the command to run with distributed builds 25 | transiently enabled (in this example, `nix-build`). All options, flags, or 26 | arguments immediately following the first positional argument are collected and 27 | fed to the command. 28 | 29 | Example: 30 | 31 | ```bash 32 | $ nix-delegate --host parnell@jenkins-slave-nixos01 --cores 4 --x86_64-linux --key /home/parnell/.ssh/awake nix-build --no-out-link -A shipit release.nix 33 | [+] Downloading: /etc/nix/signing-key.sec 34 | [+] Installing: /etc/nix/signing-key.sec 35 | [+] Downloading: /etc/nix/signing-key.pub 36 | [+] Installing: /etc/nix/signing-key.pub 37 | [+] Running command: sudo nix-build --no-out-link -A shipit release.nix 38 | [+] Full command context: sudo NIX_BUILD_HOOK=/nix/store/jj3kq2dmllvkqwwbhnmzbk9hfgncdbvl-nix-1.11.6/libexec/nix/build-remote.pl 39 | ... 40 | /nix/store/wwrrkwzhq43c22if31d65qikslmvivyc-shipit-1.0.0 41 | ``` 42 | 43 | If you are on a system with the nix-daemon running and you are not root, this 44 | utility will detect if your `nix-build` work is handled by the `nix-daemon` and 45 | if `nix-build` is supplied in the first positional argument then `sudo` will be 46 | automatically prepended to the command. 47 | 48 | Any other commands supplied are not auto-prefixed with `sudo`. 49 | -------------------------------------------------------------------------------- /Setup.hs: -------------------------------------------------------------------------------- 1 | import Distribution.Simple 2 | main = defaultMain 3 | -------------------------------------------------------------------------------- /app/Main.hs: -------------------------------------------------------------------------------- 1 | module Main where 2 | 3 | import qualified Nix.Delegate 4 | 5 | main :: IO () 6 | main = Nix.Delegate.main 7 | -------------------------------------------------------------------------------- /default.nix: -------------------------------------------------------------------------------- 1 | { mkDerivation, base, bytestring, foldl, managed 2 | , neat-interpolation, optparse-applicative, stdenv, text, turtle 3 | }: 4 | mkDerivation { 5 | pname = "nix-delegate"; 6 | version = "1.0.1"; 7 | src = ./.; 8 | isLibrary = true; 9 | isExecutable = true; 10 | libraryHaskellDepends = [ 11 | base bytestring foldl managed neat-interpolation 12 | optparse-applicative text turtle 13 | ]; 14 | executableHaskellDepends = [ base ]; 15 | description = "Convenient utility for distributed Nix builds"; 16 | license = stdenv.lib.licenses.asl20; 17 | } 18 | -------------------------------------------------------------------------------- /nix-delegate.cabal: -------------------------------------------------------------------------------- 1 | Name: nix-delegate 2 | Version: 1.0.1 3 | Cabal-Version: >=1.8.0.2 4 | Build-Type: Simple 5 | License: Apache-2.0 6 | License-File: LICENSE 7 | Copyright: 2017 Awake Networks 8 | Author: Awake Networks 9 | Maintainer: opensource@awakenetworks.com 10 | Synopsis: Convenient utility for distributed Nix builds 11 | 12 | Library 13 | Hs-Source-Dirs: src 14 | Exposed-Modules: Nix.Delegate 15 | Build-Depends: base >= 4.7.0.0 && < 5 16 | , bytestring >= 0.10.8.1 && < 1.0 17 | , neat-interpolation < 0.4 18 | , optparse-applicative >= 0.13.2.0 && < 0.15 19 | , foldl < 1.5 20 | , managed >= 1.0.3 && < 1.1 21 | , text < 1.3 22 | , turtle >= 1.3.0 && < 1.6 23 | GHC-Options: -Wall 24 | 25 | Executable nix-delegate 26 | Main-Is: Main.hs 27 | Hs-Source-Dirs: app 28 | Build-Depends: base, nix-delegate 29 | GHC-Options: -Wall 30 | -------------------------------------------------------------------------------- /release.nix: -------------------------------------------------------------------------------- 1 | # You can build this repository using Nix by running: 2 | # 3 | # $ nix-build release.nix 4 | # 5 | # You can also open up this repository inside of a Nix shell by running: 6 | # 7 | # $ nix-shell 8 | # 9 | # ... and then Nix will supply the correct Haskell development environment for 10 | # you 11 | let 12 | config = { 13 | packageOverrides = pkgs: { 14 | haskellPackages = pkgs.haskellPackages.override { 15 | overrides = haskellPackagesNew: haskellPackagesOld: { 16 | nix-delegate = haskellPackagesNew.callPackage ./default.nix { }; 17 | }; 18 | }; 19 | }; 20 | }; 21 | 22 | pkgs = 23 | import { inherit config; }; 24 | 25 | in 26 | { nix-delegate = pkgs.haskellPackages.nix-delegate; 27 | } 28 | -------------------------------------------------------------------------------- /shell.nix: -------------------------------------------------------------------------------- 1 | (import ./release.nix).nix-delegate.env 2 | -------------------------------------------------------------------------------- /src/Nix/Delegate.hs: -------------------------------------------------------------------------------- 1 | {-# LANGUAGE LambdaCase #-} 2 | {-# LANGUAGE OverloadedStrings #-} 3 | {-# LANGUAGE QuasiQuotes #-} 4 | {-# LANGUAGE RecordWildCards #-} 5 | 6 | {-| This module provides a basic library API to @nix-delegate@'s functionality 7 | -} 8 | 9 | module Nix.Delegate 10 | ( -- * Options 11 | OptArgs(..) 12 | , Command(..) 13 | , OperatingSystem(..) 14 | 15 | -- * Commands 16 | , delegate 17 | , delegateStream 18 | , main 19 | ) where 20 | 21 | import Control.Applicative (empty, many, (<**>), (<|>)) 22 | import Control.Exception (SomeException) 23 | import Control.Monad 24 | import Control.Monad.IO.Class (MonadIO) 25 | import Control.Monad.Managed (MonadManaged) 26 | import qualified Data.Foldable as Foldable 27 | import Data.Maybe 28 | import Data.Monoid ((<>)) 29 | import Data.Text (Text) 30 | import Prelude hiding (FilePath) 31 | import Turtle (ExitCode (..), FilePath, Line, 32 | Shell, d, fp, liftIO, s, (%), ()) 33 | 34 | import qualified Control.Exception 35 | import qualified Control.Foldl as Foldl 36 | import qualified Data.ByteString.Lazy 37 | import qualified Data.Text 38 | import qualified NeatInterpolation 39 | import qualified Options.Applicative as Options 40 | import qualified Options.Applicative.Types as Options 41 | import qualified Turtle 42 | import qualified Turtle.Line 43 | 44 | -- | @delegate@ options 45 | data OptArgs = OptArgs 46 | { host :: Text 47 | -- ^ Build host to add 48 | , os :: [OperatingSystem] 49 | -- ^ Supported platform types (Default: @x86_64-linux@) 50 | , key :: Maybe FilePath 51 | -- ^ SSH private key used to log in to build host (Default: @~/.ssh/id_rsa@) 52 | , cores :: Maybe Integer 53 | -- ^ Number of cores available on the build host (Default: @1@) 54 | , feature :: [Text] 55 | -- ^ Supported system features for the build host 56 | , cmd :: Command 57 | -- ^ Command to run with distributed builds enabled 58 | } deriving (Show) 59 | 60 | -- | Operating system 61 | data OperatingSystem 62 | = X86_64_Linux 63 | | X86_64_Darwin 64 | deriving (Show) 65 | 66 | parseOptions :: Options.Parser OptArgs 67 | parseOptions = 68 | OptArgs 69 | <$> parseHost 70 | <*> many parseOS 71 | <*> parseKey 72 | <*> parseCores 73 | <*> many parseFeature 74 | <*> parseCommand 75 | 76 | parseHost :: Options.Parser Text 77 | parseHost = Data.Text.pack <$> 78 | (Options.option Options.str $ 79 | ( Options.long "host" 80 | <> Options.help "Machine to use as a build slave" 81 | ) 82 | ) 83 | 84 | parseKey :: Options.Parser (Maybe FilePath) 85 | parseKey = 86 | (Options.optional $ (Turtle.fromText . Data.Text.pack) <$> 87 | (Options.option Options.str $ 88 | ( Options.long "key" 89 | <> Options.help "Path to SSH private key (Default: ~/.ssh/id_rsa)" 90 | ) 91 | ) 92 | ) 93 | 94 | parseCores :: Options.Parser (Maybe Integer) 95 | parseCores = 96 | (Options.optional 97 | (Options.option ( Options.auto) $ 98 | ( Options.long "cores" 99 | <> Options.help "Number of cores to use (Default: 1)" 100 | ) 101 | ) 102 | ) 103 | 104 | parseFeature :: Options.Parser Text 105 | parseFeature = Data.Text.pack <$> 106 | (Options.option Options.str $ 107 | ( Options.long "feature" 108 | <> Options.help "Supported system features" 109 | ) 110 | ) 111 | 112 | parseOS :: Options.Parser OperatingSystem 113 | parseOS = 114 | Options.flag' X86_64_Linux (Options.long "x86_64-linux" ) 115 | <|> Options.flag' X86_64_Darwin (Options.long "x86_64-darwin") 116 | 117 | renderOS :: OperatingSystem -> Text 118 | renderOS X86_64_Linux = "x86_64-linux" 119 | renderOS X86_64_Darwin = "x86_64-darwin" 120 | 121 | -- | Command to run 122 | data Command = Command Text [Text] 123 | deriving (Show) 124 | 125 | parseCommand :: Options.Parser Command 126 | parseCommand = parseCmd <*> many (Data.Text.pack <$> Options.strArgument (Options.metavar "ARGS")) 127 | where 128 | cmdP :: Options.ReadM ([Text] -> Command) 129 | cmdP = Command . Data.Text.pack <$> Options.readerAsk 130 | 131 | parseCmd = 132 | (Options.argument cmdP $ 133 | ( Options.metavar "COMMAND" 134 | <> Options.help "Command to delegate (if 'nix-build', sudo will be used if $NIX_REMOTE=daemon)" 135 | ) 136 | ) 137 | 138 | renderCmd :: Command -> Text 139 | renderCmd (Command cmd args) = Turtle.format (s%" "%s) cmd (Data.Text.intercalate " " args) 140 | 141 | canSudo :: Command -> Bool 142 | canSudo (Command command _) = Turtle.filename path == "nix-build" 143 | where 144 | path = Turtle.fromText command 145 | 146 | -- | @main@ used by the @delegate@ executable 147 | main :: IO () 148 | main = do 149 | options <- do 150 | Options.execParser 151 | (Options.info (parseOptions <**> Options.helper) 152 | ( Options.fullDesc 153 | <> Options.progDesc "Run a subcommand with distributed builds transiently enabled" 154 | <> Options.noIntersperse 155 | ) 156 | ) 157 | delegate options 158 | 159 | exchangeKeys :: FilePath -> Text -> IO () 160 | exchangeKeys key host = do 161 | let key' = Turtle.format fp key 162 | 163 | -- When performing a distributed build you need to share a key pair 164 | -- (both the public and private key) with the machine you're 165 | -- deploying to (or from). Both machines must store the same private 166 | -- key at `/etc/nix/signing-key.sec` and the same public key at 167 | -- `/etc/nix/signing-key.pub`. The private must also be only 168 | -- user-readable and not group- or world-readable (i.e. `400` 169 | -- permissions using `chmod` notation). 170 | -- 171 | -- By default, neither machine will have a key pair installed. This script 172 | -- will first ensure that the remote machine has a key pair (creating one if 173 | -- if missing) and copy the remote key pair to the local machine. We 174 | -- install the remote key pair locally on every run of this script because we 175 | -- do not assume that all remote machines share the same key pair. Quite the 176 | -- opposite: every production machine should have a unique signing key pair. 177 | let privateKey = "/etc/nix/signing-key.sec" 178 | let publicKey = "/etc/nix/signing-key.pub" 179 | 180 | let handler0 :: SomeException -> IO () 181 | handler0 e = do 182 | let exceptionText = Data.Text.pack (show e) 183 | let msg = [NeatInterpolation.text| 184 | [x] Could not ensure that the remote machine has signing keys installed 185 | 186 | Debugging tips: 187 | 188 | 1. Check if you can log into the remote machine by running: 189 | 190 | $ ssh -i $key' $host 191 | 192 | 2. If you can log in, then check if you have permission to `sudo` without a 193 | password by running the following command on the remote machine: 194 | 195 | $ sudo -n true 196 | $ echo $? 197 | 0 198 | 199 | Original error: $exceptionText 200 | |] 201 | Turtle.die msg 202 | 203 | let openssl :: Turtle.Format a a 204 | openssl = 205 | "$(nix-build --no-out-link \"\" -A libressl)/bin/openssl" 206 | let fmt = "ssh -i "%fp%" "%s%" '" 207 | % "test -e "%fp%" || " 208 | % "sudo sh -c \"" 209 | % "(umask 277 && "%openssl%" genrsa -out "%fp%" 2048) && " 210 | % openssl%" rsa -in "%fp%" -pubout > "%fp 211 | % "\"" 212 | % "'" 213 | let cmd = Turtle.format fmt key host privateKey privateKey privateKey publicKey 214 | Control.Exception.handle handler0 (Turtle.shells cmd empty) 215 | 216 | let mirror path = Turtle.runManaged $ do 217 | let message = Turtle.format ("[+] Downloading: "%fp) path 218 | mapM_ Turtle.err (Turtle.Line.textToLines message) 219 | 220 | localPath <- Turtle.mktempfile "/tmp" "signing-key" 221 | let download = 222 | Turtle.procs "rsync" 223 | [ "--archive" 224 | , "--checksum" 225 | , "--rsh", Turtle.format ("ssh -i "%fp) key 226 | , "--rsync-path", "sudo rsync" 227 | , Turtle.format (s%":"%fp) host path 228 | , Turtle.format fp localPath 229 | ] 230 | empty 231 | let handler1 :: SomeException -> IO () 232 | handler1 e = do 233 | let pathText = Turtle.format fp path 234 | let exceptionText = Data.Text.pack (show e) 235 | let msg = [NeatInterpolation.text| 236 | [x] Could not download: $pathText 237 | 238 | Debugging tips: 239 | 240 | 1. Check if you can log into the remote machine by running: 241 | 242 | $ ssh -i $key' $host 243 | 244 | 2. If you can log in, then check if you have permission to `sudo` without a 245 | password by running the following command on the remote machine: 246 | 247 | $ sudo -n true 248 | $ echo $? 249 | 0 250 | 251 | 3. If you can `sudo` without a password, then check if the file exists by 252 | running the following command on the remote machine: 253 | 254 | $ test -e $pathText 255 | $ echo $? 256 | 0 257 | 258 | Original error: $exceptionText 259 | |] 260 | Turtle.die msg 261 | 262 | liftIO (Control.Exception.handle handler1 download) 263 | 264 | new <- liftIO . Data.ByteString.Lazy.readFile . Data.Text.unpack $ 265 | Turtle.format fp localPath 266 | 267 | old <- liftIO . Data.ByteString.Lazy.readFile . Data.Text.unpack $ 268 | Turtle.format fp path 269 | 270 | if new == old 271 | then do 272 | let same = Turtle.format ("[+] Unchanged: "%fp) path 273 | mapM_ Turtle.err (Turtle.Line.textToLines same) 274 | else do 275 | -- NB: path shouldn't is a FilePath and won't have any 276 | -- newlines, so this should be okay 277 | Turtle.err (Turtle.unsafeTextToLine $ Turtle.format ("[+] Installing: "%fp) path) 278 | 279 | warnSudo 280 | 281 | let install = 282 | Turtle.procs "sudo" 283 | [ "mv" 284 | , Turtle.format fp localPath 285 | , Turtle.format fp path 286 | ] 287 | empty 288 | let handler2 :: SomeException -> IO () 289 | handler2 e = do 290 | let pathText = Turtle.format fp path 291 | let exceptionText = Data.Text.pack (show e) 292 | let msg = [NeatInterpolation.text| 293 | [x] Could not install: $pathText 294 | 295 | Debugging tips: 296 | 297 | 1. Check to see that you have permission to `sudo` by running: 298 | 299 | $ sudo true 300 | $ echo $? 301 | 0 302 | 303 | Original error: $exceptionText 304 | |] 305 | Turtle.die msg 306 | 307 | liftIO (Control.Exception.handle handler2 install) 308 | 309 | mirror privateKey 310 | mirror publicKey 311 | 312 | delegateShared 313 | :: MonadManaged managed => OptArgs -> managed (Text, SomeException -> IO a) 314 | delegateShared OptArgs{..} = do 315 | home <- Turtle.home 316 | let key' = fromMaybe (home ".ssh/id_rsa") key 317 | let os' = case os of [] -> [X86_64_Linux]; _ -> os 318 | let os'' = Data.Text.intercalate "," (Foldable.toList (fmap renderOS os')) 319 | let feature' = Data.Text.intercalate "," feature 320 | let cores' = fromMaybe 1 cores 321 | 322 | isDaemon <- maybe False (== "daemon") <$> Turtle.need "NIX_REMOTE" 323 | 324 | let sudo | isDaemon && canSudo cmd = "sudo" 325 | | otherwise = "" 326 | 327 | host' <- 328 | if isDaemon && not (Data.Text.any (== '@') host) 329 | then do 330 | mUser <- Turtle.need "USER" 331 | case mUser of 332 | Nothing -> Turtle.die [NeatInterpolation.text| 333 | [x] You must set the `USER` environment variable in order for `nix-delegate` to 334 | work in a multi-user Nix installation 335 | |] 336 | Just user -> return (user <> "@" <> host) 337 | else return host 338 | 339 | {- Do a test @ssh@ command in order to prompt the user to recognize the 340 | host if the host is not known 341 | 342 | Use @sudo@ if we are in multi-user mode since the @root@ user will be 343 | initiating the build and therefore the @root@ user needs to authorize 344 | the known host 345 | -} 346 | Turtle.err "[+] Testing SSH access" 347 | if sudo == "sudo" then warnSudo else return () 348 | let testSSH = s%" ssh -i "%fp%" "%s%" :" 349 | Turtle.shells (Turtle.format testSSH sudo key' host') Turtle.stdin 350 | 351 | liftIO (exchangeKeys key' host') 352 | 353 | let debuggingTips = [NeatInterpolation.text| 354 | Debugging tips: 355 | 356 | 1. Make sure that you have installed Nix: 357 | 358 | $ nix-build --version 359 | 360 | 2. Make sure that you log into a new shell after installing Nix 361 | |] 362 | 363 | remoteSystemsFile <- Turtle.mktempfile "/tmp" "remote-systems.conf" 364 | let line = 365 | Turtle.format 366 | (s%" "%s%" "%fp%" "%d%" 1 "%s) 367 | host' 368 | os'' 369 | key' 370 | cores' 371 | feature' 372 | 373 | case Turtle.textToLine line of 374 | Just line' -> 375 | Turtle.output remoteSystemsFile (pure line') 376 | Nothing -> 377 | Turtle.die [NeatInterpolation.text| 378 | [x] The generated 'remote-systems.conf' file content contains a newline (it should not) 379 | 380 | $line 381 | |] 382 | 383 | loadDirectory <- Turtle.mktempdir "/tmp" "build-remote-load" 384 | 385 | mNixPath <- Turtle.need "NIX_PATH" 386 | nixPath <- case mNixPath of 387 | Just nixPath -> return nixPath 388 | Nothing -> Turtle.die [NeatInterpolation.text| 389 | [x] Your NIX_PATH environment variable is unset 390 | 391 | $debuggingTips 392 | |] 393 | 394 | let configFile = home ".ssh/config" 395 | 396 | configExists <- Turtle.testfile configFile 397 | let sshConfigFile 398 | | configExists = Turtle.format ("ssh-config-file="%fp%":") configFile 399 | | otherwise = "" 400 | 401 | mAuthSock <- Turtle.need "SSH_AUTH_SOCK" 402 | let sshAuthSock = maybe "" (Turtle.format ("ssh-auth-sock="%s%":")) mAuthSock 403 | let nixpkgpath = Turtle.inproc "nix-build" [ "--no-out-link", "--realise", "", "--attr", "nix" ] empty 404 | 405 | hook <- Turtle.fold nixpkgpath Foldl.head >>= \case 406 | Just nixpkgpath' -> do 407 | let nixpkgfp = Turtle.fromText $ Turtle.lineToText nixpkgpath' 408 | return $ Turtle.format fp (nixpkgfp "libexec/nix/build-remote.pl") 409 | Nothing -> 410 | Turtle.die [NeatInterpolation.text| 411 | [x] The 'build-remote.pl' script could not be found on your system! 412 | 413 | $debuggingTips 414 | |] 415 | 416 | let renderedCmd = renderCmd cmd 417 | let pfxcmd = Turtle.format 418 | (s % 419 | " NIX_BUILD_HOOK="%s% 420 | " NIX_PATH="%s% 421 | " NIX_REMOTE_SYSTEMS="%s% 422 | " NIX_CURRENT_LOAD="%s%" "%s) 423 | sudo 424 | hook 425 | (sshConfigFile <> sshAuthSock <> nixPath) 426 | (Turtle.format fp remoteSystemsFile) 427 | (Turtle.format fp loadDirectory) 428 | renderedCmd 429 | 430 | let handler2 :: SomeException -> IO a 431 | handler2 e = do 432 | let exceptionText = Data.Text.pack (show e) 433 | let msg = [NeatInterpolation.text| 434 | [x] The subcommand you specified exited with a non-zero exit code: 435 | 436 | Original error: $exceptionText 437 | |] 438 | Turtle.die msg 439 | 440 | -- NB: path shouldn't is a FilePath and won't have any 441 | -- newlines, so this should be okay 442 | Turtle.err (Turtle.unsafeTextToLine $ Turtle.format ("[+] Running command: "%s%" "%s) sudo renderedCmd) 443 | Turtle.err (Turtle.unsafeTextToLine $ Turtle.format ("[+] Full command context: "%s) pfxcmd) 444 | return (pfxcmd, handler2) 445 | 446 | {-| Run a command with distributed builds transiently enabled 447 | 448 | This version outputs a helpful error message if the command fails 449 | -} 450 | delegate :: OptArgs -> IO () 451 | delegate options = Turtle.runManaged $ do 452 | (command, handler) <- delegateShared options 453 | let build = Turtle.shells command empty 454 | liftIO (Control.Exception.handle handler build) 455 | 456 | {-| Run a command with distributed builds transiently enabled 457 | 458 | This version captures the output as a stream 459 | -} 460 | delegateStream :: OptArgs -> Shell Line 461 | delegateStream options = do 462 | (command, _) <- delegateShared options 463 | Turtle.inshell command empty 464 | 465 | warnSudo :: MonadIO io => io () 466 | warnSudo = do 467 | exitCode <- Turtle.shell "sudo -n true 2>/dev/null" empty 468 | 469 | case exitCode of 470 | ExitFailure _ -> do 471 | Turtle.err "" 472 | Turtle.err " This will prompt you for your `sudo` password" 473 | _ -> do 474 | return () 475 | --------------------------------------------------------------------------------