packages feed

cloudy-0.1.0.0: src/Cloudy/Cmd/Scaleway/Create.hs

{-# LANGUAGE OverloadedRecordDot #-}

module Cloudy.Cmd.Scaleway.Create where

import Cloudy.Cli.Scaleway (ScalewayCreateCliOpts (..))
import Cloudy.Cmd.Scaleway.Utils (createAuthReq, getZone, runScalewayClientM, getInstanceType, getImageId)
import Cloudy.InstanceSetup (findInstanceSetup)
import Cloudy.InstanceSetup.Types (InstanceSetup (..), InstanceSetupData (..))
import Cloudy.LocalConfFile (LocalConfFileOpts (..), LocalConfFileScalewayOpts (..))
import Cloudy.Db (newCloudyInstance, newScalewayInstance, withCloudyDb)
import Cloudy.Scaleway (ipsPostApi, Zone (..), IpsReq (..), IpsResp (..), ProjectId (..), serversPostApi, ServersReq (..), ServersResp (..), ImageId (ImageId), serversUserDataPatchApi, UserDataKey (UserDataKey), UserData (UserData), ServersActionReq (..), serversActionPostApi, ServersRespVolume (..), ServersReqVolume (..), VolumesReq (..), volumesPatchApi, ServerId, unServerId, serversGetApi, IpId, unIpId, zoneToText, serversUserDataGetApi)
import Control.Applicative (some)
import Control.Concurrent (threadDelay)
import Control.Exception (bracket, SomeException, try)
import Control.FromSum (fromEitherM)
import Control.Monad (when, void)
import Control.Monad.IO.Class (liftIO)
import qualified Data.ByteString as ByteString
import Data.List.NonEmpty (NonEmpty (..))
import qualified Data.List.NonEmpty as NonEmpty
import qualified Data.Map.Strict as Map
import Data.Set (isSubsetOf)
import qualified Data.Set as Set
import Data.Text (Text, pack, unpack)
import Data.Text.Encoding (encodeUtf8)
import Data.Time (getCurrentTime)
import Data.Word (Word64)
import Network.Socket (AddrInfo(..), SocketType (Stream), defaultHints, getAddrInfo, openSocket, gracefulClose, connect)
import Servant.Client (ClientM)
import Servant.API (NoContent(NoContent))
import System.Directory (getHomeDirectory, copyFile)
import System.Exit (ExitCode(..))
import System.FilePath ((</>), (<.>))
import System.Process (readProcessWithExitCode)
import Text.Parsec (ParseError, parse, Parsec, newline, space, eof, sepEndBy1, digit, char, anyChar, manyTill)
import Text.Read (readMaybe)

data ScalewayCreateSettings = ScalewayCreateSettings
  { secretKey :: Text
  , projectId :: ProjectId
  , zone :: Zone
  , instanceType :: Text
  , volumeSizeGb :: Int
  , imageId :: Text
  , instanceSetup :: Maybe InstanceSetup
  }

mkSettings :: LocalConfFileOpts -> ScalewayCreateCliOpts -> IO ScalewayCreateSettings
mkSettings localConfFileOpts cliOpts = do
  let maybeSecretKey = localConfFileOpts.scaleway >>= \scale -> scale.secretKey :: Maybe Text
  secretKey <- getVal maybeSecretKey "Could not find scaleway.secret_key in config file"
  let maybeProjectId = localConfFileOpts.scaleway >>= \scale -> fmap ProjectId scale.defaultProjectId
  projectId <- getVal maybeProjectId "Could not find scaleway.default_project_id in config file"
  let maybeZoneFromConfFile = localConfFileOpts.scaleway >>= \scale -> scale.defaultZone
  zone <- getZone maybeZoneFromConfFile cliOpts.zone
  let maybeInstanceTypeFromConfFile = localConfFileOpts.scaleway >>= \scale -> scale.defaultInstanceType
      instanceType = getInstanceType maybeInstanceTypeFromConfFile cliOpts.instanceType
  let maybeImageIdFromConfFile = localConfFileOpts.scaleway >>= \scale -> scale.defaultImageId
      imageId = getImageId maybeImageIdFromConfFile cliOpts.imageId
  instanceSetup <- getInstanceSetup cliOpts.instanceSetup
  pure
    ScalewayCreateSettings
      { secretKey
      , projectId
      , zone
      , instanceType
      , volumeSizeGb = cliOpts.volumeSizeGb
      , imageId
      , instanceSetup
      }
  where
    getVal :: Maybe a -> String -> IO a
    getVal mayVal errMsg = maybe (error errMsg) pure mayVal

    getInstanceSetup :: Maybe Text -> IO (Maybe InstanceSetup)
    getInstanceSetup = \case
      -- No instance setup specified as a CLI option.  Don't use one.
      Nothing -> pure Nothing
      Just instSetupName -> do
        maybeInstSetup <- findInstanceSetup instSetupName
        case maybeInstSetup of
          -- Despite specifying an instance setup name as a CLI option, we
          -- couldn't find a corresponding instance setup. Alert the user to this
          -- problem.
          Nothing -> error $ "Couldn't find instance setup: \"" <> unpack instSetupName <> "\""
          Just instSetup -> pure $ Just instSetup

runCreate :: LocalConfFileOpts -> ScalewayCreateCliOpts -> IO ()
runCreate localConfFileOpts scalewayOpts = do
  settings <- mkSettings localConfFileOpts scalewayOpts
  withCloudyDb $ \conn -> do
    (cloudyInstanceId, instanceName) <- newCloudyInstance conn
    currentTime <- getCurrentTime
    (scalewayServerId, scalewayIpId, scalewayIpAddr) <- runScalewayClientM
      (\err -> error $ "ERROR! Problem creating instance: " <> show err)
      (createScalewayServer settings instanceName)
    newScalewayInstance
      conn
      currentTime
      cloudyInstanceId
      settings.instanceSetup
      (zoneToText settings.zone)
      (unServerId scalewayServerId)
      (unIpId scalewayIpId)
      scalewayIpAddr
    putStrLn "Waiting for Scaleway instance to become available..."
    runScalewayClientM
      (\err -> error $ "ERROR! Problem waiting for instance to be ready: " <> show err)
      (waitForScalewayServer settings scalewayServerId)
    putStrLn "Scaleway instance now available."
    putStrLn "Waiting for SSH to be ready on the instance..."
    waitForSshPort scalewayIpAddr
    putStrLn "SSH now available on the instance."
    putStrLn "Getting instance SSH key fingerprints from Scaleway metadata API..."
    rawSshKeyFingerprintsFromScalewayApi <- runScalewayClientM
      (\err -> error $ "ERROR! Problem getting instance SSH key fingerprints: " <> show err)
      (getSshKeyFingerprints settings scalewayServerId)
    putStrLn "Got instance SSH key fingerprints."
    updateSshHostKeys rawSshKeyFingerprintsFromScalewayApi scalewayIpAddr

createScalewayServer :: ScalewayCreateSettings -> Text -> ClientM (ServerId, IpId, Text)
createScalewayServer settings instanceName = do
  let authReq = createAuthReq settings.secretKey
  ipsResp <- ipsPostApi authReq settings.zone (IpsReq "routed_ipv4" settings.projectId)
  liftIO $ putStrLn $ "ips resp: " <> show ipsResp
  let serversReq =
        ServersReq
          { bootType = "local"
          , commercialType = settings.instanceType
          , image = ImageId settings.imageId
          , name = "cloudy-" <> instanceName
          , publicIps = [ipsResp.id]
          , tags = ["cloudy"]
          , volumes =
              Map.fromList
                [ ( "0"
                  , ServersReqVolume
                      { size = settings.volumeSizeGb * oneGb
                      , volumeType = "b_ssd"
                      }
                  )
                ]
          , project = settings.projectId
          }
  serversResp <- serversPostApi authReq settings.zone serversReq
  liftIO $ putStrLn $ "servers resp: " <> show serversResp
  let maybeFirstVol = Map.lookup "0" serversResp.volumes
  firstVol <- maybe (error "couldn't find first volume, unexpected") pure maybeFirstVol
  -- The volume's name has to initially be created as empty (""), and only
  -- after that can we set the name separately.
  serversVolumesResp <-
    volumesPatchApi
      authReq
      settings.zone
      firstVol.id
      (VolumesReq $ "cloudy-" <> instanceName <> "-boot-block-volume")
  liftIO $ putStrLn $ "servers volumes resp: " <> show serversVolumesResp
  case settings.instanceSetup of
    -- No instanceSetup user data, do nothing.
    Nothing -> pure ()
    Just instanceSetup -> do
      NoContent <-
        serversUserDataPatchApi
          authReq
          settings.zone
          serversResp.id
          (UserDataKey "cloud-init")
          (UserData instanceSetup.instanceSetupData.cloudInitUserData)
      liftIO $ putStrLn "created user data"
  let act = ServersActionReq { action = "poweron" }
  task <- serversActionPostApi authReq settings.zone serversResp.id act
  liftIO $ print task
  pure (serversResp.id, ipsResp.id, ipsResp.address)

oneGb :: Int
oneGb = 1000 * 1000 * 1000

waitForScalewayServer :: ScalewayCreateSettings -> ServerId -> ClientM ()
waitForScalewayServer settings serverId = do
  let authReq = createAuthReq settings.secretKey
  serversResp <- serversGetApi authReq settings.zone serverId
  when (serversResp.state /= "running") $ waitForScalewayServer settings serverId

-- | Wait for port 22 to be available on the remote machine.
waitForSshPort :: Text -> IO ()
waitForSshPort ipaddrText = do
  let hints = defaultHints { addrSocketType = Stream }
  addrInfos <- getAddrInfo (Just hints) (Just $ unpack ipaddrText) (Just "22")
  case addrInfos of
    [] -> error "Couldn't get addr info for instance"
    addrInfo : _ -> tryConnect addrInfo
  where
    tryConnect :: AddrInfo -> IO ()
    tryConnect addrInfo = do
      res <-
        try $ bracket (openSocket addrInfo) (`gracefulClose` 1000) $ \sock ->
          connect sock $ addrAddress addrInfo
      case res of
        Left (_ :: SomeException) -> do
          threadDelay 1_000_000
          tryConnect addrInfo
        Right _ -> pure ()

getSshKeyFingerprints :: ScalewayCreateSettings -> ServerId -> ClientM Text
getSshKeyFingerprints settings serverId = do
  let authReq = createAuthReq settings.secretKey
  UserData rawSshKeyFingerprints <- serversUserDataGetApi authReq settings.zone serverId (UserDataKey "ssh-host-fingerprints")
  pure rawSshKeyFingerprints

updateSshHostKeys ::
  Text ->
  -- | IP Address
  Text ->
  IO ()
updateSshHostKeys rawFingerprintsFromScalewayApi ipAddr = do
  fingerprintsFromScalewayApi <-
    fromEitherM
      (\parseErr ->
          error $
            "Error parsing SSH host fingerprints from Scaleway metadata API: " <>
            show parseErr
      )
      (parseFingerprints "scaleway-metadata-api" rawFingerprintsFromScalewayApi)
  putStrLn "Getting SSH host keys from instance..."
  rawHostKeys <- getSshHostKeys ipAddr
  putStrLn "Got SSH keys host keys from instance."
  rawFingerprintsFromHost <- fingerprintsFromHostKeys rawHostKeys
  fingerprintsFromHost <-
    fromEitherM
      (\parseErr ->
          error $
            "Error parsing SSH host fingerprints directly from instance: " <>
            show parseErr
      )
      (parseFingerprints "host" rawFingerprintsFromScalewayApi)
  case doFingerprintsMatch fingerprintsFromScalewayApi fingerprintsFromHost of
    FingerprintsMatch -> do
      putStrLn $
        "Fingerprints match between Scaleway metadata API and actual " <>
        "instance, so removing old known hosts keys for the IP address, " <>
        "and adding new known host keys..."
      removeOldHostKeysFromKnownHosts ipAddr
      addNewHostKeysToKnownHosts rawHostKeys
      putStrLn "Added known host keys for new instance."
    FingerprintsNoMatch ->
      error $
        "ERROR: Fingerprints from scaleway metadata api, and fingerprints " <>
        "directly from host don't match.\n\nFrom metadata api:\n\n" <>
        unpack rawFingerprintsFromScalewayApi <>
        "\n\nFrom host: \n\n" <>
        unpack rawFingerprintsFromHost
    FingerprintsMatchErr err ->
      error $
        "ERROR: There was an unexpected error when comparing fingerprints from " <>
        "the scaleway metadata API, and fingerprints directly from the host: " <>
        unpack err

-- | This datatype represents a line from an SSH fingerprint file, normally as
-- output by @ssh-keygen -l@.
--
-- Here's an example line:
--
-- > 3072 SHA256:dRJ/XiNOlh9UGnnN5/a2N+EMSP+OkqyHy8WTzHlUt5U root@cloudy-complete-knife (RSA)
data Fingerprint = Fingerprint
  { size :: Word64
    -- ^ Size of the key.  Example: @3072@
  , fingerprint :: Text
    -- ^ The fingerprint of the key.  Example: @"SHA256:n6fLRD4O2Me3bRXhzHyCca1vWdQ2utxuPZVsIDUm6o0"@
  , server :: Text
    -- ^ User and hostname.  Example: @"root\@cloudy-complete-knife"@
  , keyType :: Text
    -- ^ Type of key.  Example: @"RSA"@
  }
  deriving stock Show

-- | Note that we don't enforce 'server' to be same between two Fingerprints.
instance Eq Fingerprint where
  fing1 == fing2 =
    fing1.size == fing2.size &&
    fing1.fingerprint == fing2.fingerprint &&
    fing1.keyType == fing2.keyType

instance Ord Fingerprint where
  compare fing1 fing2 =
    case compare fing1.size fing2.size of
      EQ ->
        case compare fing1.fingerprint fing2.fingerprint of
          EQ -> compare fing1.keyType fing2.keyType
          res -> res
      res -> res

type Parser = Parsec Text ()

parseFingerprints ::
  -- | Where are these fingerprints coming from?
  --
  -- Just used in error output to help debugging.
  Text ->
  -- | The raw fingerprint file.  See 'Fingerprint' for what a single line of
  -- this file looks like.  The whole file is just multiple of these lines,
  -- separate by a new line.
  Text ->
  Either ParseError (NonEmpty Fingerprint)
parseFingerprints fromWhere rawFingerprintText = do
  parse (fingerprintsParser <* eof) (unpack fromWhere) rawFingerprintText

fingerprintsParser :: Parser (NonEmpty Fingerprint)
fingerprintsParser = do
  fingerprints <- sepEndBy1 fingerprintParser newline
  case fingerprints of
    [] -> error "fingerprintsParser: sepEndBy1 is never expected to return empty list"
    (h : ts) -> pure $ h :| ts

-- | Parse a single 'Fingerprint'.
--
-- >>> let finger = "3072 SHA256:dRJ/XiNOlh9UGnnN5/a2N+EMSP+OkqyHy8WTzHlUt5U root@cloudy-complete-knife (RSA)"
-- >>> parseTest fingerprintParser finger
-- Fingerprint {size = 3072, fingerprint = "SHA256:dRJ/XiNOlh9UGnnN5/a2N+EMSP+OkqyHy8WTzHlUt5U", server = "root@cloudy-complete-knife", keyType = "RSA"}
fingerprintParser :: Parser Fingerprint
fingerprintParser = do
  size <- int
  void space
  fingerprint <- pack <$> manyTill anyChar space
  server <- pack <$> manyTill anyChar space
  void $ char '('
  keyType <- pack <$> manyTill anyChar (char ')')
  pure $ Fingerprint { size, fingerprint, server, keyType }
  where
    int :: Parser Word64
    int = do
      digits <- some digit
      case readMaybe digits of
        Nothing -> fail $ "Couldn't read digits as Word64: " <> digits
        Just i -> pure i

-- | Returns the SSH host keys for the given host.
--
-- This effectively just runs @ssh-keyscan@ on the given host.
--
-- This returns an output that looks like the following:
--
-- > 123.100.200.3 ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQCiRtLMhK1Dh72tpJIXF+NjLAPPyXbq/tYC0ztDTMBFfQEj2jixURcugtGM7WjcqDCHHgnPDcSHrlkl9dMOV0MvjA2WxNupDU1bPQ31h10rIiiSjL+IB+c9e1wEgJylt72pDPzxDjdNfuAS3gspOjYNuy2vRBlV8rQ9GDlSoSvqMGbQ7W9bdCLnANsUkI+FCXFZCzIL3MU26ddqrBdCgiTvFUVxHjfFJMxwsKwLa18P6dc586mYXocmQGwjyXfJCiOw5kajvH4a9BzRr21nQT23GI2e4RlJ2Rkum9lazBNaVaQBYIUgLVVFMSfxbEt2GGBv82UKbQTbk6KHrrKE8ABYmkE81lgE+8zlnh6lxlaEQ9if6/KvtwP97g0md3hxc9b2MvGnQLEX9jjHJ/B9bHW7jJzqWRQAnCQZzenbyTht5lNK480Q9qGTu0h8FNteapzos/JnQ3B8taGQI5fpxosRLyhX3wzdQrmaAiBnILgYV2sPWZT3th0M6gsLDi4ao40=
-- > 123.100.200.3 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBJKYO35BsIkFjiAXACgkWzTC+tA2sH5RSqoYoGq8Lv+
-- > 123.100.200.3 ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBKR0UH9ZSmUyYUJfE/4mUT4SLZ9wskvsCXkVL8QNIprmFt7Zz7eRerQVyqoOm4/Zhu2OWlleqfIWOmuyPDGkImo=
getSshHostKeys ::
  -- | IP Address or hostname.  Example: @\"123.100.200.3\"@
  Text ->
  IO Text
getSshHostKeys ipAddr = do
  (exitCode, stdout, stderr) <- readProcessWithExitCode "ssh-keyscan" [unpack ipAddr] ""
  case exitCode of
    ExitFailure _ -> do
      error $
        "getSshHostKeys: error running ssh-keyscan on ip " <> unpack ipAddr <> ": " <>
        stderr
    ExitSuccess -> pure $ pack stdout

-- | Return the fingerprints for a set of raw host keys.
--
-- This effectively just runs @ssh-keygen -l@ on a set of raw host keys.
--
-- See the docs for 'Fingerprint' for an example of what this function outputs.
fingerprintsFromHostKeys ::
  -- | A newline-separated file of SSH host keys.  See the output of
  -- 'getSshHostKeys' for what this should look like.
  Text ->
  IO Text
fingerprintsFromHostKeys rawHostKeys = do
  (exitCode, stdout, stderr) <-
    readProcessWithExitCode "ssh-keygen" ["-l", "-f", "-"] (unpack rawHostKeys)
  case exitCode of
    ExitFailure _ -> do
      error $
        "fingerprintsFromHostKeys: error running ssh-keygen on raw host keys: " <>
        stderr
    ExitSuccess -> pure $ pack stdout

-- | Results of comparing whether two sets of fingerprints match.
data FingerprintsMatch
  = FingerprintsMatch
  | FingerprintsNoMatch
  -- | There was some error when comparing the two sets of fingerprints.
  | FingerprintsMatchErr Text
  deriving stock Show

doFingerprintsMatch :: NonEmpty Fingerprint -> NonEmpty Fingerprint -> FingerprintsMatch
doFingerprintsMatch fings1 fings2 =
  let fingsLen1 = length fings1
      fingsLen2 = length fings2
      fingsSet1 = Set.fromList $ NonEmpty.toList fings1
      fingsSet2 = Set.fromList $ NonEmpty.toList fings2
      fingsSetLen1 = length fingsSet1
      fingsSetLen2 = length fingsSet2
  in
  if fingsLen1 /= fingsSetLen1 then FingerprintsMatchErr "first set of fingerprints is not unique" else
  if fingsLen2 /= fingsSetLen2 then FingerprintsMatchErr "second set of fingerprints is not unique" else
  if fingsSetLen1 /= fingsSetLen2 then FingerprintsMatchErr "two sets of fingerprints have different numbers of fingerprints" else
  if isSubsetOf fingsSet1 fingsSet2 && isSubsetOf fingsSet2 fingsSet1
    then FingerprintsMatch
    else FingerprintsNoMatch

-- | Remove old, out-of-date host keys from the user's @~/.ssh/known_hosts@ file.
--
-- This effectively just runs @ssh-keygen -R@ on the passed-in IP address.
removeOldHostKeysFromKnownHosts ::
  -- | IP Address or hostname.  Example: @\"123.100.200.3\"@.
  Text ->
  IO ()
removeOldHostKeysFromKnownHosts ipAddr = do
  (exitCode, _, stderr) <- readProcessWithExitCode "ssh-keygen" ["-R", unpack ipAddr] ""
  case exitCode of
    ExitFailure _ -> do
      error $
        "remove: removeOldHostKeysFromKnownHosts error running ssh-keygen -R on IP " <> unpack ipAddr <> ": " <>
        stderr
    ExitSuccess -> pure ()

-- | Add a set of new SSH host keys to the @~/.ssh/known_hosts@ file.
--
-- This effectively just appends the passed-in host keys to the file.
addNewHostKeysToKnownHosts ::
  -- | A set of SSH host keys.  See the output of 'getSshHostKeys' for
  -- what this should look like.
  Text ->
  IO ()
addNewHostKeysToKnownHosts newSshHostKeys = do
  homeDir <- getHomeDirectory
  let knownHosts = homeDir </> ".ssh" </> "known_hosts"
      knownHostsOld = knownHosts <.> "old"
      newSshHostKeysRaw = encodeUtf8 newSshHostKeys
  -- make copy of known hosts
  copyFile knownHosts knownHostsOld
  ByteString.appendFile knownHosts ("\n" <> newSshHostKeysRaw)

-- $setup
--
-- >>> import Text.Parsec (parseTest)