packages feed

cachix-1.1: src/Cachix/Deploy/Activate.hs

{-# LANGUAGE CPP #-}
{-# LANGUAGE DuplicateRecordFields #-}

module Cachix.Deploy.Activate where

import qualified Cachix.API.WebSocketSubprotocol as WSS
import qualified Cachix.Client.InstallationMode as InstallationMode
import qualified Cachix.Client.NetRc as NetRc
import Cachix.Client.URI (URI)
import qualified Cachix.Client.URI as URI
import qualified Cachix.Deploy.Log as Log
import qualified Cachix.Types.BinaryCache as BinaryCache
import Cachix.Types.Permission (Permission (..))
import qualified Data.Aeson as Aeson
#if MIN_VERSION_aeson(2,0,0)
import qualified Data.Aeson.KeyMap as HM
#else
import qualified Data.HashMap.Strict as HM
#endif
import qualified Data.Conduit.Combinators as Conduit
import qualified Data.Conduit.Process as Conduit
import qualified Data.Vector as Vector
import Protolude hiding (log, toS)
import Protolude.Conv (toS)
import Servant.Auth.Client (Token (..))
import qualified System.Directory as Directory
import System.FilePath ((</>))
import System.IO.Temp (withSystemTempDirectory)
import System.Process
import Prelude (String)

data Status
  = Success
  | Failure FailureReason
  | Rollback FailureReason
  deriving (Show, Exception)

data FailureReason
  = NetworkTestFailure
  | RollbackScriptExitFailure
  | RollbackScriptUnexpectedError IOException
  | ShellCommandFailure {command :: String, exitCode :: Int}
  | UnexpectedError SomeException
  deriving (Show)

instance Exception FailureReason where
  displayException = \case
    NetworkTestFailure -> "Cannot connect back to Cachix Deploy after activating the new deployment"
    RollbackScriptExitFailure -> "The rollback script returned a non-zero exit code"
    RollbackScriptUnexpectedError e -> "Cannot run rollback script: " <> displayException e
    ShellCommandFailure {command, exitCode} ->
      toS $
        unwords
          [ "Failed to run " <> toS command,
            show exitCode
          ]
    UnexpectedError e ->
      toS $
        unwords
          [ "The deployment failed with an unexpected error:",
            toS (displayException e)
          ]

downloadStorePaths ::
  -- | Logging context
  Log.LogStream ->
  -- | Deployment details
  WSS.DeploymentDetails ->
  -- | Binary cache args
  [String] ->
  IO ()
downloadStorePaths logStream deploymentDetails cacheArgs = do
  -- Download the store path from the binary cache
  -- TODO: add GC root so it's preserved for the next command
  runShell logStream "nix-store" (["-r", toS storePath] <> cacheArgs)

  -- Download the rollback script, if provided
  for_ (WSS.rollbackScript deploymentDetails) $ \script ->
    runShell logStream "nix-store" (["-r", toS script] <> cacheArgs)
  where
    storePath = WSS.storePath deploymentDetails

type RollbackAction = IO ()

-- | Activate the profile and return a rollback action if available.
activate ::
  -- | Logging context
  Log.LogStream ->
  -- | Profile name
  Text ->
  -- | Store path to activate
  FilePath ->
  -- | Returns a rollback action if available
  IO (Maybe RollbackAction)
activate logStream profileName storePath = do
  (profilePath, activationScripts) <- getActivationScript profileName storePath
  previousProfilePath <- toStorePath profilePath

  -- Activate the configuration
  -- TODO: Check with Domen whether we can exit early here
  forM_ activationScripts $ uncurry (runShell logStream)

  pure $ Just rollback <*> previousProfilePath
  where
    toStorePath profilePath = do
      profileExists <- Directory.doesPathExist profilePath
      if profileExists
        then Just <$> Directory.canonicalizePath profilePath
        else pure Nothing

    -- We can't use '--rollback' because it just selects the next generation
    -- down from our deployment, which is not necessarily the generation that
    -- was previously active.
    rollback = void . activate logStream profileName

type Command = (String, [String])

getActivationScript :: Text -> FilePath -> IO (FilePath, [Command])
getActivationScript profile storePath = do
  let checkPath p = Directory.doesPathExist $ toS storePath </> p
  isNixOS <- checkPath "nixos-version"
  isNixDarwin <- checkPath "darwin-version"
  isHomeManager <- checkPath "hm-version"
  user <- InstallationMode.getUser
  let systemProfileDir = "/nix/var/nix/profiles"
  let perUserProfileDir = systemProfileDir </> "per-user" </> toS user
  let mkProfilePath profileBaseDir defaultProfile =
        profileBaseDir </> if profile == "" then defaultProfile else toS profile
  -- Sets the new profile. This is needed for NixOS and nix-darwin. The Home
  -- Manager activation script does the profile setting by itself.
  -- TODO: document what happens if the wrong user is used for the agent
  let setNewProfile profilePath =
        ("nix-env", ["-p", profilePath, "--set", storePath])
  return $ case (isNixOS, isNixDarwin, isHomeManager) of
    (True, _, _) ->
      let profilePath = mkProfilePath systemProfileDir "system"
       in ( profilePath,
            [ setNewProfile profilePath,
              (toS storePath </> "bin/switch-to-configuration", ["switch"])
            ]
          )
    (_, True, _) ->
      -- https://github.com/LnL7/nix-darwin/blob/master/pkgs/nix-tools/darwin-rebuild.sh
      let profilePath = mkProfilePath systemProfileDir "system-profiles/system"
       in ( profilePath,
            [ ("mkdir", ["-p", "-m", "0755", "/nix/var/nix/profiles/system-profiles"]),
              setNewProfile profilePath,
              (toS storePath </> "activate-user", []),
              (toS storePath </> "activate", [])
            ]
          )
    (_, _, True) ->
      ( mkProfilePath perUserProfileDir "home-manager",
        [(toS storePath </> "activate", [])]
      )
    (_, _, _) ->
      let profilePath = mkProfilePath systemProfileDir "system"
       in (profilePath, [setNewProfile profilePath])

-- TODO: send errors as well
-- TODO: fix either/maybe types
getClosureSize :: [String] -> Text -> IO (Either Text (Maybe Int64))
getClosureSize cacheArgs storePath = do
  (exitCode, pathInfoJSON, nixError) <- readProcessWithExitCode "nix" (cacheArgs <> ["--extra-experimental-features", "nix-command", "path-info", "-S", "--json", toS storePath]) ""
  case exitCode of
    ExitFailure _ -> pure $ Left (toS nixError)
    ExitSuccess -> pure $ Right $ Aeson.decode (toS pathInfoJSON) >>= extractClosureSize

extractClosureSize :: Aeson.Value -> Maybe Int64
extractClosureSize (Aeson.Array vector) = case Vector.toList vector of
  [Aeson.Object obj] -> case HM.lookup "closureSize" obj of
    Just (Aeson.Number num) -> Just $ floor num
    _ -> Nothing
  _ -> Nothing
extractClosureSize _ = Nothing

-- TODO: don't create tmpfile for public caches
withCacheArgs :: URI -> WSS.AgentInformation -> Text -> ([String] -> IO a) -> IO a
withCacheArgs host agentInfo agentToken m =
  withSystemTempDirectory "netrc" $ \dir -> do
    let filepath = dir </> "netrc"
    args <- case WSS.cache agentInfo of
      Just cache -> do
        let cacheName = WSS.cacheName cache
        let cacheURI = URI.appendSubdomain cacheName host
        -- TODO: ugh
        let bc =
              BinaryCache.BinaryCache
                { BinaryCache.name = "",
                  BinaryCache.uri = URI.serialize cacheURI,
                  BinaryCache.publicSigningKeys = [],
                  BinaryCache.isPublic = WSS.isPublic cache,
                  BinaryCache.githubUsername = "",
                  BinaryCache.permission = Read,
                  BinaryCache.preferredCompressionMethod = BinaryCache.XZ
                }
        NetRc.add (Token (toS agentToken)) [bc] filepath
        return $ cachesArgs cache <> ["--option", "netrc-file", filepath]
      Nothing ->
        return []
    m args
  where
    cachesArgs :: WSS.Cache -> [String]
    cachesArgs cache =
      let cacheName = WSS.cacheName cache
          cacheURI = URI.appendSubdomain cacheName host
          hostname = (URI.hostBS . URI.getHostname) cacheURI
          officialCache = "cache.nixos.org-1:6NCHdD59X431o0gWypbMrAURkbJ16ZPMQFGspcDShjY="
          substituters = ["--option", "extra-substituters", URI.serialize cacheURI]
          noNegativeCaching = ["--option", "narinfo-cache-negative-ttl", "0"]
          sigs = ["--option", "trusted-public-keys", officialCache <> " " <> toS hostname <> "-1:" <> toS (WSS.publicKey cache)]
       in substituters ++ sigs ++ noNegativeCaching

runShell :: Log.LogStream -> FilePath -> [String] -> IO ()
runShell logStream cmd args = runShellWithExitCode logStream cmd args >>= handleError
  where
    handleError (ExitFailure exitCode) = throwIO $ ShellCommandFailure {command = cmd, exitCode}
    handleError ExitSuccess = pure ()

runShellWithExitCode :: Log.LogStream -> FilePath -> [String] -> IO ExitCode
runShellWithExitCode logStream cmd args = do
  Log.streamLine logStream $ "$ " <> toS cmd <> " " <> toS (unwords $ fmap toS args)
  (exitCode, _, _) <- Conduit.sourceProcessWithStreams (proc cmd args) Conduit.sinkNull logStream logStream
  pure exitCode