1{-# LANGUAGE FlexibleContexts #-}
2{-# LANGUAGE LambdaCase #-}
3{-# LANGUAGE OverloadedStrings #-}
4{-# LANGUAGE ScopedTypeVariables #-}
5{-# LANGUAGE ViewPatterns #-}
6{-# LANGUAGE DeriveGeneric #-}
7{-# LANGUAGE TypeApplications #-}
8module Obelisk.Command.Deploy where
12import Control.Monad.Catch (Exception (displayException), MonadThrow, throwM, try, bracket_)
13import Control.Monad.IO.Class (liftIO)
14import Data.Aeson (FromJSON, ToJSON, encode, eitherDecode)
16import qualified Data.ByteString.Lazy as BSL
18import qualified Data.HashMap.Strict as HM
19import qualified Data.Map as Map
20import Data.Text (Text)
21import qualified Data.Set as Set
22import qualified Data.Text as T
23import qualified Data.Text.IO as T
28import System.Directory
29import System.Environment (getEnvironment)
32import System.Posix.Files
33import System.Process (delegate_ctlc, env, proc, cwd)
35import qualified Text.URI as URI
38import Obelisk.App (MonadObelisk)
39import Obelisk.CliApp (Severity (..), callProcessAndLogOutput, failWith, putLog, withSpinner)
40import Obelisk.Command.Nix
41import Obelisk.Command.Project
42import Obelisk.Command.Thunk
43import Obelisk.Command.Utils
50 -> [String] -- ^ hostnames
52 -> String -- ^ admin email
53 -> Bool -- ^ enable https
55deployInit thunkPtr deployDir sshKeyPath hostnames route adminEmail enableHttps = do
56 localKey <- withSpinner ("Preparing " <> T.pack deployDir) $ do
57 localKey <- liftIO (doesFileExist sshKeyPath) >>= \case
58 False -> failWith $ T.pack $ "ob deploy init: file does not exist: " <> sshKeyPath
59 True -> pure $ deployDir </> "ssh_key"
60 callProcessAndLogOutput (Notice, Error) $
61 proc "cp" [sshKeyPath, localKey]
62 liftIO $ setFileMode localKey $ ownerReadMode .|. ownerWriteMode
64 withSpinner "Validating configuration" $ do
65 void $ getHostFromRoute enableHttps route -- make sure that hostname is present
66 forM_ hostnames $ \hostname -> do
67 putLog Notice $ "Verifying host keys (" <> T.pack hostname <> ")"
68 -- Note: we can't use a spinner here as this function will prompt the user.
69 verifyHostKey (deployDir </> "backend_known_hosts") localKey hostname
70 --IMPORTANT: We cannot copy config directory from the development project to
71 --the deployment directory. If we do, it's very likely someone will
72 --accidentally create a production deployment that uses development
73 --credentials to connect to some resources. This could result in, e.g.,
74 --production data backed up to a dev environment.
75 withSpinner "Creating project configuration directories" $ do
76 callProcessAndLogOutput (Notice, Error) $
78 , deployDir </> "config" </> "backend"
79 , deployDir </> "config" </> "common"
80 , deployDir </> "config" </> "frontend"
82 withSpinner "Writing deployment configuration" $ do
83 writeDeployConfig deployDir "backend_hosts" $ unlines hostnames
84 writeDeployConfig deployDir "enable_https" $ show enableHttps
85 writeDeployConfig deployDir "admin_email" adminEmail
86 writeDeployConfig deployDir ("config" </> "common" </> "route") $ route
87 withSpinner "Creating source thunk (./src)" $ liftIO $ do
88 createThunk (deployDir </> "src") thunkPtr
89 setupObeliskImpl deployDir
90 withSpinner ("Initializing git repository (" <> T.pack deployDir <> ")") $
93setupObeliskImpl :: FilePath -> IO ()
94setupObeliskImpl deployDir = do
95 let implDir = toImplDir deployDir
96 createDirectoryIfMissing True implDir
97 writeFile (implDir </> "default.nix") "(import ../../src {}).obelisk"
99deployPush :: MonadObelisk m => FilePath -> m [String] -> m ()
100deployPush deployPath getNixBuilders = do
101 hosts <- Set.fromList . filter (/= mempty) . lines <$> readDeployConfig deployPath "backend_hosts"
102 adminEmail <- readDeployConfig deployPath "admin_email"
103 enableHttps <- read <$> readDeployConfig deployPath "enable_https"
104 route <- readDeployConfig deployPath $ "config" </> "common" </> "route"
105 routeHost <- getHostFromRoute enableHttps route
106 let srcPath = deployPath </> "src"
107 thunkPtr <- readThunk srcPath >>= \case
108 Right (ThunkData_Packed ptr) -> return ptr
109 Right (ThunkData_Checkout _) -> do
110 checkGitCleanStatus srcPath True >>= \case
111 True -> packThunk False srcPath
112 False -> failWith $ T.pack $ "ob deploy push: ensure " <> srcPath <> " has no pending changes and latest is pushed upstream."
113 Left err -> failWith $ "ob deploy push: couldn't read src thunk: " <> T.pack (show err)
114 let version = show . _thunkRev_commit $ _thunkPtr_rev thunkPtr
115 builders <- getNixBuilders
116 buildOutputByHost <- ifor (Map.fromSet (const ()) hosts) $ \host () -> do
117 --TODO: What does it mean if this returns more or less than 1 line of output?
118 [result] <- fmap lines $ nixCmd $ NixCmd_Build $ def
119 & nixCmdConfig_target .~ Target
120 { _target_path = Just srcPath
121 , _target_attr = Just "server.system"
122 , _target_expr = Nothing
124 & nixBuildConfig_outLink .~ OutLink_None
125 & nixCmdConfig_args .~
126 [ strArg "hostName" host
127 , strArg "adminEmail" adminEmail
128 , strArg "routeHost" routeHost
129 , strArg "version" version
130 , boolArg "enableHttps" enableHttps
132 & nixCmdConfig_builders .~ builders
134 let knownHostsPath = deployPath </> "backend_known_hosts"
135 sshOpts = sshArgs knownHostsPath (deployPath </> "ssh_key") False
136 withSpinner "Uploading closures" $ ifor_ buildOutputByHost $ \host outputPath -> do
138 (Map.fromList [("NIX_SSHOPTS", unwords sshOpts)])
139 "nix-copy-closure" ["-v", "--to", "--use-substitutes", "root@" <> host, "--gzip", outputPath]
140 withSpinner "Uploading config" $ ifor_ buildOutputByHost $ \host _ -> do
141 callProcessAndLogOutput (Notice, Warning) $
143 [ "-e ssh " <> unwords sshOpts
145 , deployPath </> "config"
146 , "root@" <> host <> ":/var/lib/backend"
148 --TODO: Create GC root so we're sure our closure won't go away during this time period
149 withSpinner "Switching to new configuration" $ ifor_ buildOutputByHost $ \host outputPath -> do
150 callProcessAndLogOutput (Notice, Warning) $
151 proc "ssh" $ sshOpts <>
154 [ "nix-env -p /nix/var/nix/profiles/system --set " <> outputPath
156 , "/nix/var/nix/profiles/system/bin/switch-to-configuration switch"
159 isClean <- checkGitCleanStatus deployPath True
160 when (not isClean) $ do
161 withSpinner "Commiting changes to Git" $ do
162 callProcessAndLogOutput (Debug, Error) $ proc "git"
163 ["-C", deployPath, "add", "."]
164 callProcessAndLogOutput (Debug, Error) $ proc "git"
165 ["-C", deployPath, "commit", "-m", "New deployment"]
166 putLog Notice $ "Deployed => " <> T.pack route
168 callProcess' envMap cmd args = do
169 processEnv <- Map.toList . (envMap <>) . Map.fromList <$> liftIO getEnvironment
170 let p = (proc cmd args) { delegate_ctlc = True, env = Just processEnv }
171 callProcessAndLogOutput (Notice, Notice) p
173deployUpdate :: MonadObelisk m => FilePath -> m ()
174deployUpdate deployPath = updateThunkToLatest (deployPath </> "src") Nothing
176keytoolToAndroidConfig :: KeytoolConfig -> HM.HashMap Text (NValueNF Identity)
177keytoolToAndroidConfig conf = runIdentity $ do
178 path <- toValue $ Path $ _keytoolConfig_keystore conf
179 storepass <- toValue $ T.pack $ _keytoolConfig_storepass conf
180 alias <- toValue $ T.pack $ _keytoolConfig_alias conf
181 keypass <- toValue $ T.pack $ _keytoolConfig_keypass conf
183 [ ("storeFile", path)
184 , ("storePassword", storepass)
185 , ("keyAlias", alias)
186 , ("keyPassword", keypass)
189data PlatformDeployment = Android | IOS
192renderPlatformDeployment :: PlatformDeployment -> String
193renderPlatformDeployment = \case
197deployMobile :: MonadObelisk m => PlatformDeployment -> [String] -> m ()
198deployMobile platform mobileArgs = withProjectRoot "." $ \root -> do
199 let srcDir = root </> "src"
200 configDir = root </> "config"
201 exists <- liftIO $ doesDirectoryExist srcDir
202 unless exists $ failWith "ob test should be run inside of a deploy directory"
203 nixBuildTarget <- case platform of
205 let keystorePath = root </> "android_keystore.jks"
206 keytoolConfPath = root </> "android_keytool_config.json"
207 hasKeystore <- liftIO $ doesFileExist keystorePath
208 when (not hasKeystore) $ do
209 -- TODO log instructions for how to modify the keystore
210 putLog Notice $ "Creating keystore: " <> T.pack keystorePath
211 putLog Notice "Enter a keystore password: "
212 keyStorePassword <- liftIO $ withEcho False getLine
213 putLog Notice "Re-enter the keystore password: "
214 keyStorePassword' <- liftIO $ withEcho False getLine
215 unless (keyStorePassword' == keyStorePassword) $ failWith "passwords do not match"
216 let keyToolConf = KeytoolConfig
217 { _keytoolConfig_keystore = keystorePath
218 , _keytoolConfig_alias = "obelisk"
219 , _keytoolConfig_storepass = keyStorePassword
220 , _keytoolConfig_keypass = keyStorePassword
222 createKeystore root $ keyToolConf
223 liftIO $ BSL.writeFile keytoolConfPath $ encode keyToolConf
224 checkKeytoolConfExist <- liftIO $ doesFileExist keytoolConfPath
225 unless checkKeytoolConfExist $ failWith "Missing android KeytoolConfig"
226 keytoolConfContents <- liftIO $ BSL.readFile keytoolConfPath
227 liftIO $ putStrLn $ show keytoolConfContents
228 releaseKey <- case eitherDecode keytoolConfContents of
229 Left err -> failWith $ T.pack err
231 let nvset = toValue @(HM.HashMap Text (NValueNF Identity)) @Identity @(NValueNF Identity) $ keytoolToAndroidConfig conf
232 return $ printNix $ runIdentity nvset
234 [ "with (import ", srcDir, " {});"
235 , "android.frontend.override (drv: { "
236 , "releaseKey = (if builtins.isNull drv.releaseKey then {} else drv.releaseKey) // " <> releaseKey <> "; "
237 , "staticSrc = (passthru.__androidWithConfig ", configDir, ").frontend.staticSrc;"
241 { _target_path = Nothing
242 , _target_attr = Nothing
243 , _target_expr = Just expr
247 [ "with (import ", srcDir, " {});"
248 , "ios.frontend.override (_: { staticSrc = (passthru.__iosWithConfig ", configDir, ").frontend.staticSrc; })"
251 { _target_path = Nothing
252 , _target_attr = Nothing
253 , _target_expr = Just expr
255 result <- nixCmd $ NixCmd_Build $ def
256 & nixBuildConfig_outLink .~ OutLink_None
257 & nixCmdConfig_target .~ nixBuildTarget
258 putLog Notice $ T.pack $ "Your recently built android apk can be found at the following path: " <> (show result)
259 callProcessAndLogOutput (Notice, Error) $ proc (result </> "bin" </> "deploy") mobileArgs
261 withEcho showEcho f = do
262 prevEcho <- hGetEcho stdin
263 bracket_ (hSetEcho stdin showEcho) (hSetEcho stdin prevEcho) f
265data KeytoolConfig = KeytoolConfig
266 { _keytoolConfig_keystore :: FilePath
267 , _keytoolConfig_alias :: String
268 , _keytoolConfig_storepass :: String
269 , _keytoolConfig_keypass :: String
270 } deriving (Show, Generic)
272instance FromJSON KeytoolConfig
273instance ToJSON KeytoolConfig
275createKeystore :: MonadObelisk m => FilePath -> KeytoolConfig -> m ()
276createKeystore root config = do
277 let expr = "with (import " <> toImplDir root <> ").reflex-platform.nixpkgs; pkgs.mkShell { buildInputs = [ pkgs.jdk ]; }"
278 callProcessAndLogOutput (Notice,Notice) $ (proc "nix-shell" ["-E" , expr, "--run" , keytoolCmd]) { cwd = Just root }
280 keytoolCmd = processToShellString "keytool"
281 [ "-genkeypair", "-noprompt"
282 , "-keystore", _keytoolConfig_keystore config
283 , "-keyalg", "RSA", "-keysize", "2048"
284 , "-validity", "1000000"
285 , "-storepass", _keytoolConfig_storepass config
286 , "-alias", _keytoolConfig_alias config
287 , "-keypass", _keytoolConfig_keypass config
290-- | Simplified deployment configuration mechanism. At one point we may revisit this.
291writeDeployConfig :: MonadObelisk m => FilePath -> FilePath -> String -> m ()
292writeDeployConfig deployDir fname = liftIO . writeFile (deployDir </> fname)
294readDeployConfig :: MonadObelisk m => FilePath -> FilePath -> m String
295readDeployConfig deployDir fname = liftIO $ do
296 fmap (T.unpack . T.strip) $ T.readFile $ deployDir </> fname
298verifyHostKey :: MonadObelisk m => FilePath -> FilePath -> String -> m ()
299verifyHostKey knownHostsPath keyPath hostName =
300 callProcessAndLogOutput (Notice, Warning) $ proc "ssh" $
301 sshArgs knownHostsPath keyPath True <>
302 [ "root@" <> hostName
303 , "-o", "NumberOfPasswordPrompts=0"
307sshArgs :: FilePath -> FilePath -> Bool -> [String]
308sshArgs knownHostsPath keyPath askHostKeyCheck =
309 [ "-o", "UserKnownHostsFile=" <> knownHostsPath
310 , "-o", "StrictHostKeyChecking=" <> if askHostKeyCheck then "ask" else "yes"
314-- common/route validation
315-- TODO: move these to executable-config once the typed-config stuff is done.
318 = InvalidRoute_NotHttps URI
319 | InvalidRoute_MissingScheme URI
320 | InvalidRoute_MissingHost URI
321 | InvalidRoute_HasPort URI
322 | InvalidRoute_HasPath URI
325instance Exception InvalidRoute where
326 displayException = \case
327 InvalidRoute_MissingScheme uri -> route uri "must have an URI scheme"
328 InvalidRoute_NotHttps uri -> route uri "must be HTTPS"
329 InvalidRoute_MissingHost uri -> route uri "must contain a hostname"
330 InvalidRoute_HasPort uri -> route uri "cannot specify port"
331 InvalidRoute_HasPath uri -> route uri "cannot contain path"
333 route uri err = T.unpack $ "Route (" <> URI.render uri <> ") " <> err
335-- | Get the hostname from a https route
337-- Fail if the route is invalid (i.e, no host present or scheme is not https)
340 => Bool -- ^ Ensure https?
343getHostFromRoute mustBeHttps route = do
344 result :: Either InvalidRoute String <- try $ do
345 validateCommonRouteAndGetHost mustBeHttps =<< URI.mkURI (T.strip $ T.pack route)
346 either (failWith . T.pack . displayException) pure result
348validateCommonRouteAndGetHost
349 :: (MonadThrow m, MonadObelisk m)
350 => Bool -- ^ Ensure https?
353validateCommonRouteAndGetHost mustBeHttps uri = do
354 case uri ^? uriScheme of
355 Just (Just (URI.unRText -> s)) -> case (mustBeHttps, s) of
356 (False, _) -> pure ()
357 (True, "https") -> pure ()
358 _ -> throwM $ InvalidRoute_NotHttps uri
359 _ -> throwM $ InvalidRoute_MissingScheme uri
360 case uri ^. uriPath of
362 _path -> throwM $ InvalidRoute_HasPath uri
363 case uri ^? uriAuthority . _Right . authPort of
364 Just (Just _port) -> throwM $ InvalidRoute_HasPort uri
366 case uri ^? uriAuthority . _Right . authHost of
367 Nothing -> throwM $ InvalidRoute_MissingHost uri
368 Just sslHost -> return $ T.unpack $ URI.unRText sslHost
keysesc to close
- j k
- move down, up
- enter
- open
- esc
- back, or up a level
- 1 2 3 4
- readme, files, commits, refs
- /
- filter
- y
- copy the clone command
- c
- compare the chosen commit with another (or shift-click both)
- ?
- show or hide this list