nix-deploy (empty) → 1.0.0
raw patch · 6 files changed
+627/−0 lines, 6 filesdep +basedep +neat-interpolationdep +optparse-applicativesetup-changed
Dependencies added: base, neat-interpolation, optparse-applicative, optparse-generic, text, turtle
Files
- CHANGELOG.md +3/−0
- LICENSE +13/−0
- README.md +73/−0
- Setup.hs +2/−0
- nix-deploy.cabal +43/−0
- src/Main.hs +493/−0
+ CHANGELOG.md view
@@ -0,0 +1,3 @@+# 1.0.0++- Initial release
+ LICENSE view
@@ -0,0 +1,13 @@+Copyright 2017 Awake Networks++Licensed under the Apache License, Version 2.0 (the "License");+you may not use this file except in compliance with the License.+You may obtain a copy of the License at++ http://www.apache.org/licenses/LICENSE-2.0++Unless required by applicable law or agreed to in writing, software+distributed under the License is distributed on an "AS IS" BASIS,+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.+See the License for the specific language governing permissions and+limitations under the License.
+ README.md view
@@ -0,0 +1,73 @@+# `nix-deploy`+Deploy a NixOS system configuration with `nix-deploy system ...` to a remote+machine and switch the machine to that system configuration. You can also deploy+a nix store path with `nix-deploy path ...` to a remote machine or from a remote+machine.++This tool is often used in conjunction with [`nix-delegate`](https://github.com/awakesecurity/nix-delegate).++```shell+$ nix-deploy --help+Deploy software or an entire NixOS system configuration to another NixOS system++Usage: nix-deploy (path | system)++Available options:+ -h,--help Show this help text++Available commands:+ path+ system+```++```shell+$ nix-deploy path --help+Usage: nix-deploy path (--to USER@HOST | --from USER@HOST) [--sudo] [--noSign]+ [--path FILEPATH] [--profilePath FILEPATH]+ [--profileName LINE]++Available options:+ -h,--help Show this help text+ --to USER@HOST Deploy software to this address (ex:+ user@192.168.0.1)+ --from USER@HOST Deploy software from this address (ex:+ user@192.168.0.1)+ --sudo Prepend with sudo+ --noSign Don't sign payload (not recommended)+ --path FILEPATH Path to deploy+ --profilePath FILEPATH Path to parent profile directory (default:+ /nix/var/nix/profiles)+ --profileName LINE Name of profile to set (example: upgrade-tools)+```++```shell+$ nix-deploy system --help+Usage: nix-deploy system (--to USER@HOST | --from USER@HOST) [--noSign]+ [--path FILEPATH] [--systemName LINE] ([--switch] |+ [--boot] | [--test] | [--dry-activate] | [--reboot])++Available options:+ -h,--help Show this help text+ --to USER@HOST Deploy software to this address (ex:+ user@192.168.0.1)+ --from USER@HOST Deploy software from this address (ex:+ user@192.168.0.1)+ --noSign Don't sign payload (not recommended)+ --path FILEPATH Path to deploy+ --systemName LINE Alternative system profile name (default: system)+```++## Usage example+```shell+$ nix-deploy --to parnell@remote-server --path $(nix-build --no-out-link --attr foo ~/Development/bar/release.nix)+[+] Downloading: /etc/nix/signing-key.sec+[+] Installing: /etc/nix/signing-key.sec+[+] Downloading: /etc/nix/signing-key.pub+[+] Installing: /etc/nix/signing-key.pub+copying 178 missing paths (474.70 MiB) to ‘parnell@remote-server’...+...+[+] Copying /nix/store/q4c3avwb0szbsg8pkv7x32gcqz4g0wwa-foo-0.1.0.0++copying 4 missing paths (31.83 MiB) to ‘parnell@remote-server’...++```
+ Setup.hs view
@@ -0,0 +1,2 @@+import Distribution.Simple+main = defaultMain
+ nix-deploy.cabal view
@@ -0,0 +1,43 @@+name: nix-deploy+version: 1.0.0+synopsis: Deploy Nix-built software to a NixOS machine+homepage: https://github.com/awakesecurity/nix-deploy#readme+license: Apache-2.0+license-file: LICENSE+author: Awake Networks+maintainer: opensource@awakenetworks.com+copyright: 2017 Awake Networks+category: System+build-type: Simple+cabal-version: >=1.10++description:++ Deploy a NixOS system configuration with @nix-deploy system ...@ to+ a remote machine and switch the machine to that system+ configuration. You can also deploy a nix store path with @nix-deploy+ path ...@ to a remote machine or from a remote machine.+ .+ This tool is often used in conjunction with <https://github.com/awakesecurity/nix-delegate nix-delegate>.++extra-source-files:+ LICENSE+ README.md+ CHANGELOG.md++executable nix-deploy+ hs-source-dirs: src+ main-is: Main.hs+ ghc-options: -Wall+ build-depends:+ base >= 4.9.0.0 && < 5+ , optparse-generic >= 1.2.0 && < 1.3+ , optparse-applicative >= 0.13.0.0 && < 0.15+ , neat-interpolation < 0.4+ , text >= 0.7 && < 1.3+ , turtle >= 1.3.3 && < 1.4.3+ default-language: Haskell2010++source-repository head+ type: git+ location: https://github.com/awakesecurity/nix-deploy.git
+ src/Main.hs view
@@ -0,0 +1,493 @@+{-# LANGUAGE DataKinds #-}+{-# LANGUAGE DeriveAnyClass #-}+{-# LANGUAGE DeriveGeneric #-}+{-# LANGUAGE DuplicateRecordFields #-}+{-# LANGUAGE FlexibleInstances #-}+{-# LANGUAGE LambdaCase #-}+{-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE QuasiQuotes #-}+{-# LANGUAGE RecordWildCards #-}+{-# LANGUAGE ScopedTypeVariables #-}+{-# LANGUAGE StandaloneDeriving #-}+{-# LANGUAGE TypeOperators #-}+{-# LANGUAGE ViewPatterns #-}++{-# OPTIONS -fno-warn-orphans #-}+{-# OPTIONS -fno-warn-unused-do-bind #-}++module Main where++import Control.Applicative (empty, (<|>))+import Control.Exception (SomeException)+import qualified Control.Exception+import Control.Monad+import Control.Monad.IO.Class (MonadIO)+import qualified Data.List.NonEmpty as NonEmpty+import Data.Maybe+import Data.Monoid ((<>))+import Data.Text (Text)+import qualified Data.Text as Text+import qualified Data.Text.IO as Text.IO+import qualified NeatInterpolation as Neat+import qualified Options.Applicative as Options+import Options.Generic+import Prelude hiding (FilePath)+import qualified System.IO+import Turtle (ExitCode (..), FilePath, fp, liftIO, s,+ (%), (</>))+import qualified Turtle+import Turtle.Line++data Options w+ = Path+ { direction :: Direction+ , sudo :: w ::: Bool <?> "Prepend with sudo"+ , noSign :: w ::: Bool <?> "Don't sign payload (not recommended)"+ , path :: w ::: Maybe FilePath <?> "Path to deploy"+ , profilePath :: w ::: Maybe FilePath <?> "Path to parent profile directory (default: /nix/var/nix/profiles)"+ , profileName :: w ::: Maybe Line <?> "Name of profile to set (example: upgrade-tools)"++ }+ | System+ { direction :: Direction+ , noSign :: w ::: Bool <?> "Don't sign payload (not recommended)"+ , path :: w ::: Maybe FilePath <?> "Path to deploy"+ , systemName :: w ::: Maybe Line <?> "Alternative system profile name (default: system)"+ , switchMethod :: Maybe SwitchMethod+ }+ deriving (Generic)++instance ParseRecord (Options Wrapped)+deriving instance Show (Options Unwrapped)++data SwitchMethod+ = Switch+ | Boot+ | Test+ | DryActivate+ | Reboot+ -- ^ Same as `Boot` except followed by a @reboot@+ deriving (Eq, Show, ParseFields)++instance ParseField SwitchMethod where+ parseField _ _ _ =+ Options.flag' Switch (Options.long "switch")+ <|> Options.flag' Boot (Options.long "boot")+ <|> Options.flag' Test (Options.long "test")+ <|> Options.flag' DryActivate (Options.long "dry-activate")+ <|> Options.flag' Reboot (Options.long "reboot")++instance ParseRecord SwitchMethod where+ parseRecord = fmap Options.Generic.getOnly parseRecord++renderSwitch :: SwitchMethod -> Text+renderSwitch Switch = "switch"+renderSwitch Boot = "boot"+renderSwitch Test = "test"+renderSwitch DryActivate = "dry-activate"+renderSwitch Reboot = "boot"++data Direction = To Line | From Line+ deriving (Show, ParseFields)++instance ParseField Direction where+ parseField _ _ _ = (To <$> parseTo) <|> (From <$> parseFrom)+ where+ parseTo = parser "to" "Deploy software to this address (ex: user@192.168.0.1)"+ parseFrom = parser "from" "Deploy software from this address (ex: user@192.168.0.1)"+ line = Options.maybeReader (textToLine . Text.pack)+ parser l h =+ (Options.option line $+ ( Options.metavar "USER@HOST"+ <> Options.long l+ <> Options.help h+ )+ )++instance ParseRecord Direction where+ parseRecord = fmap Options.Generic.getOnly parseRecord++instance ParseRecord Line where+ parseRecord = fmap Options.Generic.getOnly parseRecord++instance ParseFields Line where++instance ParseField Line where+ parseField h m c = do+ let metavar = "LINE"+ let line = Options.maybeReader (textToLine . Text.pack)+ case m of+ Nothing ->+ (Options.argument line+ ( Options.metavar metavar+ <> foldMap (Options.help . Text.unpack) h))+ Just name ->+ (Options.option line+ ( Options.metavar metavar+ <> foldMap Options.short c+ <> Options.long (Text.unpack name)+ <> foldMap (Options.help . Text.unpack) h))++renderDirection :: Direction -> (Text, Line)+renderDirection (To target) = ("to", target)+renderDirection (From target) = ("from", target)++progSummary :: Text+progSummary = "Deploy software or an entire NixOS system configuration to another NixOS system"++main :: IO ()+main =+ unwrapRecord progSummary >>= \case+ Path{..} -> do+ pathText <-+ case path of+ Just p -> pure (Turtle.format fp p)+ Nothing -> liftIO pathFromStdin++ let (txDir, target) = renderDirection direction+ let targetText = lineToText target++ let sign = not noSign+ when sign $ exchangeKeys targetText++ stderrLines [Neat.text|[+] Copying $pathText|]+ let transfer =+ Turtle.procs "nix-copy-closure"+ ((if sign then ["--sign"] else []) <>+ [ (Turtle.format ("--"%s) txDir)+ , "--gzip"+ , targetText+ , pathText+ ])+ empty++ -- Transfer path to target+ liftIO (Control.Exception.catch transfer (errorHandler [Neat.text|+[x] Failed transferring $pathText $txDir $targetText++ 1. $pathText does not exist+ 2. Make sure you have an authorized key configured on $targetText+|]))++ let sudoAnyway =+ case profilePath of+ Nothing -> True -- The default profile path requires `sudo`+ _ -> sudo+ updateProfile line = do+ let profile = fromMaybe "/nix/var/nix/profiles" profilePath+ </> Turtle.fromText (lineToText line)+ let profileText = Turtle.format fp profile+ setProfile direction sudoAnyway profileText pathText+ mapM_ updateProfile profileName++ System{..} -> do++ pathText <-+ case path of+ Just p -> pure (Turtle.format fp p)+ Nothing -> liftIO pathFromStdin++ let profileText =+ case systemName of+ Nothing -> "/nix/var/nix/profiles/system"+ Just p ->+ let profiles = Turtle.fromText "/nix/var/nix/profiles/system-profiles"+ name = Turtle.fromText (lineToText p)+ in Turtle.format fp (profiles </> name)++ let (txDir, target) = renderDirection direction+ let targetText = lineToText target++ let sign = not noSign+ when sign $ exchangeKeys targetText++ stderrLines [Neat.text|[+] Installing system: $pathText|]++ let transfer =+ Turtle.procs "nix-copy-closure"+ ((if sign then ["--sign"] else []) <>+ [ (Turtle.format ("--"%s) txDir)+ , "--gzip"+ , targetText+ , pathText+ ])+ empty++ let method = fromMaybe Test switchMethod+ let switchSystem =+ Turtle.procs "ssh"+ [ targetText+ , "sudo"+ , "/nix/var/nix/profiles/system/bin/switch-to-configuration"+ , (renderSwitch method)+ ]+ empty++ -- Transfer path to target+ liftIO (Control.Exception.catch transfer (errorHandler [Neat.text|+[x] Failed transferring $pathText $txDir $targetText++ 1. $pathText may not exist, make sure you built it with nix-build first+ 2. Make sure you have an authorized key configured on $targetText so that you can SSH+|]))++ setProfile direction True profileText pathText++ -- Switch to the new system configuration+ liftIO (Control.Exception.catch switchSystem (errorHandler [Neat.text|+[x] Failed to switch $targetText to $pathText++ You need `sudo` privileges on the target machine+|]))+ when (method == Reboot)$ do+ let success = + stderrLines [Neat.text|[+] $pathText successfully activated, $targetText is rebooting|]+ rebootCmd targetText >>= \case+ -- This is the exit code returned by `ssh` when the machine closes+ -- the connection due to a successful reboot. We can't really+ -- distinguish the connection being closed for other reasons,+ -- unfortunately, so we have to assume that this meant success+ ExitFailure 255 -> success+ ExitFailure _ ->+ Turtle.die [Neat.text|[x] Failed to reboot $targetText after activating $pathText at $profileText|]+ -- The command should always fail because the remote machine closes+ -- connection when rebooting, but we include this case for+ -- completeness+ ExitSuccess -> success++-- | Given a 'Text' that may have newlines, split using+-- 'Turtle.Line.textToLines' and print each line to stderr.+stderrLines :: MonadIO io => Text -> io ()+stderrLines = mapM_ Turtle.err . textToLines++-- | Given an error preamble and 'SomeException', format the exception+-- message and render it with the preamble into a useful error.+errorHandler :: MonadIO io => Text -> SomeException -> io ()+errorHandler msg err = do+ let excText0 = Text.justifyRight 4 ' ' <$> Text.lines (Text.pack $ show err)+ let excText1 = Text.unlines excText0++ Turtle.die [Neat.text|+$msg++Original error was:++$excText1+|]+++rebootCmd :: MonadIO io => Text -> io Turtle.ExitCode+rebootCmd target = Turtle.shell [Neat.text|ssh $target sudo reboot|] empty++pathFromStdin :: IO Text+pathFromStdin = do+ let h = System.IO.stdin+ System.IO.hWaitForInput h (-1)++ ls <- Turtle.textToLines <$> Text.IO.hGetContents h++ pure (Turtle.lineToText $ NonEmpty.head ls)+++exchangeKeys :: Text -> IO ()+exchangeKeys host = do+ -- When performing a distributed build you need to share a key pair+ -- (both the public and private key) with the machine you're+ -- deploying to (or from). Both machines must store the same private+ -- key at `/etc/nix/signing-key.sec` and the same public key at+ -- `/etc/nix/signing-key.pub`. The private must also be only+ -- user-readable and not group- or world-readable (i.e. `400`+ -- permissions using `chmod` notation).+ --+ -- By default, neither machine will have a key pair installed. This script+ -- will first ensure that the remote machine has a key pair (creating one if+ -- if missing) and copy the remote key pair to the local machine. We+ -- install the remote key pair locally on every run of this script because we+ -- do not assume that all remote machines share the same key pair. Quite the+ -- opposite: every production machine should have a unique signing key pair.+ let privateKey = "/etc/nix/signing-key.sec"+ let publicKey = "/etc/nix/signing-key.pub"++ let handler0 :: SomeException -> IO ()+ handler0 e = do+ let exceptionText = Text.pack (show e)+ let msg = [Neat.text|+[x] Could not ensure that the remote machine has signing keys installed++ Debugging tips:++ 1. Check if you can log into the remote machine by running:++ $ ssh $host++ 2. If you can log in, then check if you have permission to `sudo` without a+ password by running the following command on the remote machine:++ $ sudo -n true+ $ echo $?+ 0++ Original error: $exceptionText+|]+ Turtle.die msg++ let openssl :: Turtle.Format a a+ openssl =+ "$(nix-build --no-out-link \"<nixpkgs>\" -A libressl)/bin/openssl"+ let fmt = "ssh "%s%" '"+ % "test -e "%fp%" || "+ % "sudo sh -c \""+ % "(umask 277 && "%openssl%" genrsa -out "%fp%" 2048) && "+ % openssl%" rsa -in "%fp%" -pubout > "%fp+ % "\""+ % "'"+ let cmd = Turtle.format fmt host privateKey privateKey privateKey publicKey+ Control.Exception.handle handler0 (Turtle.shells cmd empty)++ let mirror path = Turtle.runManaged $ do+ -- NB: path shouldn't is a FilePath and won't have any+ -- newlines, so this should be okay+ stderrLines (Turtle.format ("[+] Downloading: "%fp) path)++ localPath <- Turtle.mktempfile "/tmp" "signing-key"+ let download =+ Turtle.procs "rsync"+ [ "--archive"+ , "--checksum"+ , "--rsh", "ssh"+ , "--rsync-path", "sudo rsync"+ , Turtle.format (s%":"%fp) host path+ , Turtle.format fp localPath+ ]+ empty+ let handler1 :: SomeException -> IO ()+ handler1 e = do+ let pathText = Turtle.format fp path+ let exceptionText = Text.pack (show e)+ let msg = [Neat.text|+[x] Could not download: $pathText++ Debugging tips:++ 1. Check if you can log into the remote machine by running:++ $ ssh $host++ 2. If you can log in, then check if you have permission to `sudo` without a+ password by running the following command on the remote machine:++ $ sudo -n true+ $ echo $?+ 0++ 3. If you can `sudo` without a password, then check if the file exists by+ running the following command on the remote machine:++ $ test -e $pathText+ $ echo $?+ 0++ Original error: $exceptionText+|]+ Turtle.die msg++ liftIO (Control.Exception.handle handler1 download)++ exitCode <- Turtle.shell "sudo -n true 2>/dev/null" empty++ -- NB: path shouldn't is a FilePath and won't have any+ -- newlines, so this should be okay+ Turtle.err (Turtle.unsafeTextToLine $ Turtle.format ("[+] Installing: "%fp) path)++ case exitCode of+ ExitFailure _ -> do+ Turtle.err ""+ Turtle.err " This will prompt you for your `sudo` password"+ _ -> do+ return ()++ let install =+ Turtle.procs "sudo"+ [ "mv"+ , Turtle.format fp localPath+ , Turtle.format fp path+ ]+ empty+ let handler2 :: SomeException -> IO ()+ handler2 e = do+ let pathText = Turtle.format fp path+ let exceptionText = Text.pack (show e)+ let msg = [Neat.text|+[x] Could not install: $pathText++ Debugging tips:++ 1. Check to see that you have permission to `sudo` by running:++ $ sudo true+ $ echo $?+ 0++ Original error: $exceptionText+|]+ Turtle.die msg++ liftIO (Control.Exception.handle handler2 install)++ mirror privateKey+ mirror publicKey+++setProfile+ :: MonadIO io+ => Direction+ -> Bool+ -- ^ `True` to use `sudo`+ -> Text+ -- ^ Profile name (such as @"system"@, or @"default"@)+ -> Text+ -- ^ File path to install (rendered as `Text`)+ -> io ()+setProfile direction sudo profileText pathText = do+ command <- case direction of+ To target -> do+ let command =+ Turtle.procs "ssh"+ ( [ lineToText target+ ]+ ++ (if sudo then [ "sudo" ] else [])+ ++ [ "nix-env"+ , "--profile"+ , profileText+ , "--set"+ , pathText+ ]+ )+ empty+ return command+ From _ -> do+ let command =+ if sudo+ then+ Turtle.procs "sudo"+ [ "nix-env"+ , "--profile"+ , profileText+ , "--set"+ , pathText+ ]+ empty+ else+ Turtle.procs "nix-env"+ [ "--profile"+ , profileText+ , "--set"+ , pathText+ ]+ empty++ return command++ -- Set or create a profile pointing at the transferred path+ let msg = [Neat.text|[x] Failed setting $profileText to $pathText|]+ liftIO (Control.Exception.handle (errorHandler msg) command)