on master help sign in
ob deploy: HTTPs support

Also remove obelisk-https

commit2a6a278233f302197ae52603952bd4f555d3e5cc authorSridhar Ratnakumar <srid@srid.ca> dateMon 2 Jul 2018, 11:48 (8 years ago) parent280af4b
10 files changed+205−65
README.md+15−9 default.nix+22−13 lib/command/obelisk-command.cabal+2 lib/command/src/Obelisk/Command.hs+11−1 lib/command/src/Obelisk/Command/Deploy.hs+121−24 lib/command/src/Obelisk/Command/Nix.hs+19−5 lib/https/default.nix−7 lib/https/github.json−6 migration/6a18939264224d6ce24c7426249edfea-e66afc8befac4d76b5780a960f768c31/obelisk-handoff migration/6a18939264224d6ce24c7426249edfea-e66afc8befac4d76b5780a960f768c31/obelisk-upgrade+15
README.md+15−9
@@ -116,22 +116,28 @@ First create a new EC2 instance:
1161161. Launch a NixOS 17.09 EC2 instance (we recommend [this AMI](https://console.aws.amazon.com/ec2/v2/home?region=us-east-1#LaunchInstanceWizard:ami=ami-40bee63a))
1171171. In the instance configuration wizard ensure that your instance has at least 1GB RAM and 10GB disk space.
1181181. When prompted save your AWS private key (`~/myaws.pem`) somewhere safe. We'll need it later during deployment.
119−1. Go to "Security Groups", select your instance's security group and under "Inbound" tab add a new rule for HTTP port 80.
119+1. Go to "Security Groups", select your instance's security group and under "Inbound" tab add a new rule for HTTP port 80 and 443.
120120
121−At this stage your instance should be booting and become accessible shortly. Note down the hostname of your instance. It should look like this:
122−
123−```
124−INSTANCE_HOSTNAME=ec2-??-??-??-??.ca-central-1.compute.amazonaws.com
125−```
121+At this stage your instance should be booting and become accessible shortly. Note down the hostname of your EC2 instance.
126122
127123Now go to your Obelisk project directory (`~/code/myapp`), and initialize a deployment config (`~/code/myapp-deploy`):
128124Your project directory must be "thunkable", i.e. something on which `ob thunk pack` can be called. Usually it will be a git repository whose current revision has been pushed upstream.
129125
130126```
131127cd ~/code/myapp
132−ob deploy init --ssh-key ~/myaws.pem --hostname ${INSTANCE_HOSTNAME} ~/code/myapp-deploy
128+SERVER=ec2-35-183-22-197.ca-central-1.compute.amazonaws.com
129+ROUTE=https://myapp.com # Publicly accessible route to your app
130+EMAIL=myname@myapp.com
131+ob deploy init \
132+ --ssh-key ~/myaws.pem \
133+ --hostname $SERVER \
134+ --route $ROUTE \
135+ --admin-email $EMAIL \
136+ ~/code/myapp-deploy
133137```
134138
139+NOTE: HTTPS is enabled by default; to disable https, pass `--disable-https` to the `ob deploy init` command above.
140+
135141Then go to that created deployment configuration directory, and initiate the deployment:
136142
137143```
@@ -139,9 +145,9 @@ cd ~/code/myapp-deploy
139145ob deploy push
140146```
141147
142−`ob deploy push` will locally build your app and then transfer it, along with all the Nix package dependencies, via ssh to the EC2 instance. It will also configure Nginx so that the public port 80 proxies to the running app.
148+`ob deploy push` will locally build your app and then transfer it, along with all the Nix package dependencies, via ssh to the EC2 instance.
143149
144−At this point you are done. Your app will be accessible at `http://${HOSTNAME}`!
150+At this point you are done. Your app will be accessible at `${ROUTE}`.
145151
146152### Deploying an updated version
147153
default.nix+22−13
@@ -192,25 +192,33 @@ rec {
192192 ln -s ${compressedJs frontend} $out/frontend.jsexe
193193 ''; #TODO: run frontend.jsexe through the asset processing pipeline
194194
195− server = exe: hostName:
195+ server = { exe, hostName, adminEmail, routeHost, enableHttps }:
196196 let system = "x86_64-linux";
197197 nixos = import (pkgs.path + /nixos);
198− https = (import lib/https {}).module {
199− backendPort = 8000; # TODO read from config
200− # sslConfig = {
201− # hostName = "example.com";
202− # adminEmail = "webmaster@example.com";
203− # subdomains = [ "www" ];
204− # };
205− };
198+ backendPort = 8000;
206199 in nixos {
207200 inherit system;
208201 configuration = args: {
209202 imports = [
210203 (pkgs.path + /nixos/modules/virtualisation/amazon-image.nix)
211− https
212204 ];
213− networking = { inherit hostName; };
205+ networking = {
206+ inherit hostName;
207+ firewall.allowedTCPPorts = if enableHttps then [ 80 443 ] else [ 80 ];
208+ };
209+ services.nginx = {
210+ enable = true;
211+ virtualHosts."${routeHost}" = {
212+ enableACME = enableHttps;
213+ forceSSL = enableHttps;
214+ locations."/" = {
215+ proxyPass = "http://localhost:" + toString backendPort;
216+ };
217+ };
218+ };
219+ security.acme.certs = if enableHttps then {
220+ "${routeHost}".email = adminEmail;
221+ } else { };
214222 systemd.services.backend = {
215223 wantedBy = [ "multi-user.target" ];
216224 after = [ "network.target" ];
@@ -303,8 +311,9 @@ rec {
303311 in projectOut system // {
304312 inherit linuxExe;
305313 exe = serverOn system;
306− server = { hostName }: server linuxExe hostName;
307− obelisk = import (base + /.obelisk/impl) {};
314+ server = args@{ hostName, adminEmail, routeHost, enableHttps }:
315+ server (args // { exe = linuxExe;});
316+ obelisk = import (base + "/.obelisk/impl") {};
308317 };
309318 haskellPackageSets = {
310319 ghc = reflex-platform.ghc.override {
lib/command/obelisk-command.cabal+2
@@ -27,6 +27,8 @@ library
2727 , io-streams
2828 , exceptions
2929 , logging-effect
30+ , lens
31+ , modern-uri
3032 , monad-loops
3133 , mtl
3234 , network
lib/command/src/Obelisk/Command.hs+11−1
@@ -175,6 +175,9 @@ deployInitOpts = DeployInitOpts
175175 <$> strArgument (action "directory" <> metavar "DEPLOYDIR" <> help "Path to a directory that it will create")
176176 <*> strOption (long "ssh-key" <> action "file" <> metavar "SSHKEY" <> help "Path to an ssh key that it will symlink to")
177177 <*> some (strOption (long "hostname" <> metavar "HOSTNAME" <> help "hostname of the deployment target"))
178+ <*> strOption (long "route" <> metavar "PUBLICROUTE" <> help "Publicly accessible URL of your app")
179+ <*> strOption (long "admin-email" <> metavar "ADMINEMAIL" <> help "Email address where administrative alerts will be sent")
180+ <*> flag True False (long "disable-https" <> help "Disable automatic https configuration for the backend")
178181 <*> strOption (long "upstream" <> value "origin" <> metavar "REMOTE" <> help "git remote to use for the src thunk" <> showDefault)
179182
180183type TeamID = String
@@ -195,6 +198,9 @@ data DeployInitOpts = DeployInitOpts
195198 { _deployInitOpts_outputDir :: FilePath
196199 , _deployInitOpts_sshKey :: FilePath
197200 , _deployInitOpts_hostname :: [String]
201+ , _deployInitOpts_route :: String
202+ , _deployInitOpts_adminEmail :: String
203+ , _deployInitOpts_enableHttps :: Bool
198204 , _deployInitOpts_remote :: String
199205 }
200206 deriving Show
@@ -376,7 +382,11 @@ ob = \case
376382 getThunkPtr' False root (T.pack $ _deployInitOpts_remote deployOpts)
377383 let sshKeyPath = _deployInitOpts_sshKey deployOpts
378384 hostname = _deployInitOpts_hostname deployOpts
379− deployInit thunkPtr (root </> "config") deployDir sshKeyPath hostname
385+ route = _deployInitOpts_route deployOpts
386+ adminEmail = _deployInitOpts_adminEmail deployOpts
387+ enableHttps = _deployInitOpts_enableHttps deployOpts
388+ deployInit thunkPtr (root </> "config") deployDir
389+ sshKeyPath hostname route adminEmail enableHttps
380390 DeployCommand_Push remoteBuilder -> deployPush "." $ case remoteBuilder of
381391 Nothing -> pure []
382392 Just RemoteBuilder_ObeliskVM -> (:[]) <$> VmBuilder.getNixBuildersArg
lib/command/src/Obelisk/Command/Deploy.hs+121−24
@@ -2,9 +2,12 @@
22{-# LANGUAGE LambdaCase #-}
33{-# LANGUAGE OverloadedStrings #-}
44{-# LANGUAGE ScopedTypeVariables #-}
5+{-# LANGUAGE ViewPatterns #-}
56module Obelisk.Command.Deploy where
67
8+import Control.Lens
79import Control.Monad
10+import Control.Monad.Catch (Exception (displayException), MonadThrow, throwM, try)
811import Control.Monad.IO.Class (liftIO)
912import Data.Bits
1013import Data.Default
@@ -18,6 +21,9 @@ import System.Environment (getEnvironment)
1821import System.FilePath
1922import System.Posix.Files
2023import System.Process (delegate_ctlc, env, proc)
24+import Text.URI (URI)
25+import qualified Text.URI as URI
26+import Text.URI.Lens
2127
2228import Obelisk.App (MonadObelisk)
2329import Obelisk.CliApp (Severity (..), callProcessAndLogOutput, failWith, putLog, withSpinner)
@@ -26,30 +32,48 @@ import Obelisk.Command.Project
2632import Obelisk.Command.Thunk
2733import Obelisk.Command.Utils
2834
29−deployInit :: MonadObelisk m => ThunkPtr -> FilePath -> FilePath -> FilePath -> [String] -> m ()
30−deployInit thunkPtr configDir deployDir sshKeyPath hostnames = do
31− hasConfigDir <- liftIO $ do
32− createDirectoryIfMissing True deployDir
33− doesDirectoryExist configDir
34− localKey <- liftIO (doesFileExist sshKeyPath) >>= \case
35− False -> failWith $ T.pack $ "ob deploy init: file does not exist: " <> sshKeyPath
36− True -> pure $ deployDir </> "ssh_key"
37− callProcessAndLogOutput (Notice, Error) $ proc "cp" [sshKeyPath, localKey]
38− liftIO $ setFileMode localKey $ ownerReadMode .|. ownerWriteMode
39− liftIO $ writeFile (deployDir </> "backend_hosts") $ unlines hostnames
35+deployInit
36+ :: MonadObelisk m
37+ => ThunkPtr
38+ -> FilePath
39+ -> FilePath
40+ -> FilePath
41+ -> [String] -- ^ hostnames
42+ -> String -- ^ route
43+ -> String -- ^ admin email
44+ -> Bool -- ^ enable https
45+ -> m ()
46+deployInit thunkPtr configDir deployDir sshKeyPath hostnames route adminEmail enableHttps = do
47+ (hasConfigDir, localKey) <- withSpinner ("Preparing " <> T.pack deployDir) $ do
48+ hasConfigDir <- liftIO $ do
49+ createDirectoryIfMissing True deployDir
50+ doesDirectoryExist configDir
51+ localKey <- liftIO (doesFileExist sshKeyPath) >>= \case
52+ False -> failWith $ T.pack $ "ob deploy init: file does not exist: " <> sshKeyPath
53+ True -> pure $ deployDir </> "ssh_key"
54+ callProcessAndLogOutput (Notice, Error) $
55+ proc "cp" [sshKeyPath, localKey]
56+ liftIO $ setFileMode localKey $ ownerReadMode .|. ownerWriteMode
57+ return $ (hasConfigDir, localKey)
58+ withSpinner "Validating configuration" $ do
59+ void $ getHostFromRoute enableHttps route -- make sure that hostname is present
4060 forM_ hostnames $ \hostname -> do
4161 putLog Notice $ "Verifying host keys (" <> T.pack hostname <> ")"
62+ -- Note: we can't use a spinner here as this function will prompt the user.
4263 verifyHostKey (deployDir </> "backend_known_hosts") localKey hostname
43− when hasConfigDir $ do
44− callProcessAndLogOutput (Notice, Error) $ proc "cp"
45− [ "-r"
46− , "-T"
47− , configDir
48− , deployDir </> "config"
49− ]
50− liftIO $ createThunk (deployDir </> "src") thunkPtr
51− liftIO $ setupObeliskImpl deployDir
52− initGit deployDir
64+ when hasConfigDir $ withSpinner "Importing project configuration" $ do
65+ callProcessAndLogOutput (Notice, Error) $
66+ proc "cp" [ "-r" , "-T" , configDir , deployDir </> "config"]
67+ withSpinner "Writing deployment configuration" $ do
68+ writeDeployConfig deployDir "backend_hosts" $ unlines hostnames
69+ writeDeployConfig deployDir "enable_https" $ show enableHttps
70+ writeDeployConfig deployDir "admin_email" adminEmail
71+ writeDeployConfig deployDir ("config" </> "common" </> "route") $ route
72+ withSpinner "Creating source thunk (./src)" $ liftIO $ do
73+ createThunk (deployDir </> "src") thunkPtr
74+ setupObeliskImpl deployDir
75+ withSpinner ("Initializing git repository (" <> T.pack deployDir <> ")") $
76+ initGit deployDir
5377
5478setupObeliskImpl :: FilePath -> IO ()
5579setupObeliskImpl deployDir = do
@@ -59,8 +83,11 @@ setupObeliskImpl deployDir = do
5983
6084deployPush :: MonadObelisk m => FilePath -> m [String] -> m ()
6185deployPush deployPath getNixBuilders = do
62− let backendHosts = deployPath </> "backend_hosts"
63− host <- liftIO $ fmap (T.unpack . T.strip) $ T.readFile backendHosts
86+ host <- readDeployConfig deployPath "backend_hosts"
87+ adminEmail <- readDeployConfig deployPath "admin_email"
88+ enableHttps <- read <$> readDeployConfig deployPath "enable_https"
89+ route <- readDeployConfig deployPath $ "config" </> "common" </> "route"
90+ routeHost <- getHostFromRoute enableHttps route
6491 let srcPath = deployPath </> "src"
6592 build = do
6693 builders <- getNixBuilders
@@ -70,7 +97,12 @@ deployPush deployPath getNixBuilders = do
7097 , _target_attr = Just "server"
7198 }
7299 , _nixBuildConfig_outLink = OutLink_None
73− , _nixBuildConfig_args = pure $ Arg "hostName" host
100+ , _nixBuildConfig_args =
101+ [ strArg "hostName" host
102+ , strArg "adminEmail" adminEmail
103+ , strArg "routeHost" routeHost
104+ , boolArg "enableHttps" enableHttps
105+ ]
74106 , _nixBuildConfig_builders = builders
75107 }
76108 return $ listToMaybe $ lines buildOutput
@@ -114,6 +146,7 @@ deployPush deployPath getNixBuilders = do
114146 ["-C", deployPath, "add", "."]
115147 callProcessAndLogOutput (Debug, Error) $ proc "git"
116148 ["-C", deployPath, "commit", "-m", "New deployment"]
149+ putLog Notice $ "Deployed => " <> T.pack route
117150 where
118151 callProcess' envMap cmd args = do
119152 processEnv <- Map.toList . (envMap <>) . Map.fromList <$> liftIO getEnvironment
@@ -131,6 +164,14 @@ deployMobile platform mobileArgs = withProjectRoot "." $ \root -> do
131164 result <- nixBuildAttrWithCache srcDir $ platform <> ".frontend"
132165 callProcessAndLogOutput (Notice, Error) $ proc (result </> "bin" </> "deploy") mobileArgs
133166
167+-- | Simplified deployment configuration mechanism. At one point we may revisit this.
168+writeDeployConfig :: MonadObelisk m => FilePath -> FilePath -> String -> m ()
169+writeDeployConfig deployDir fname = liftIO . writeFile (deployDir </> fname)
170+
171+readDeployConfig :: MonadObelisk m => FilePath -> FilePath -> m String
172+readDeployConfig deployDir fname = liftIO $ do
173+ fmap (T.unpack . T.strip) $ T.readFile $ deployDir </> fname
174+
134175verifyHostKey :: MonadObelisk m => FilePath -> FilePath -> String -> m ()
135176verifyHostKey knownHostsPath keyPath hostName =
136177 callProcessAndLogOutput (Notice, Warning) $ proc "ssh" $
@@ -145,3 +186,59 @@ sshArgs knownHostsPath keyPath askHostKeyCheck =
145186 , "-o", "StrictHostKeyChecking=" <> if askHostKeyCheck then "ask" else "yes"
146187 , "-i", keyPath
147188 ]
189+
190+-- common/route validation
191+-- TODO: move these to executable-config once the typed-config stuff is done.
192+
193+data InvalidRoute
194+ = InvalidRoute_NotHttps URI
195+ | InvalidRoute_MissingScheme URI
196+ | InvalidRoute_MissingHost URI
197+ | InvalidRoute_HasPort URI
198+ | InvalidRoute_HasPath URI
199+ deriving Show
200+
201+instance Exception InvalidRoute where
202+ displayException = \case
203+ InvalidRoute_MissingScheme uri -> route uri "must have an URI scheme"
204+ InvalidRoute_NotHttps uri -> route uri "must be HTTPS"
205+ InvalidRoute_MissingHost uri -> route uri "must contain a hostname"
206+ InvalidRoute_HasPort uri -> route uri "cannot specify port"
207+ InvalidRoute_HasPath uri -> route uri "cannot contain path"
208+ where
209+ route uri err = T.unpack $ "Route (" <> URI.render uri <> ") " <> err
210+
211+-- | Get the hostname from a https route
212+--
213+-- Fail if the route is invalid (i.e, no host present or scheme is not https)
214+getHostFromRoute
215+ :: MonadObelisk m
216+ => Bool -- ^ Ensure https?
217+ -> String
218+ -> m String
219+getHostFromRoute mustBeHttps route = do
220+ result :: Either InvalidRoute String <- try $ do
221+ validateCommonRouteAndGetHost mustBeHttps =<< URI.mkURI (T.strip $ T.pack route)
222+ either (failWith . T.pack . displayException) pure result
223+
224+validateCommonRouteAndGetHost
225+ :: (MonadThrow m, MonadObelisk m)
226+ => Bool -- ^ Ensure https?
227+ -> URI
228+ -> m String
229+validateCommonRouteAndGetHost mustBeHttps uri = do
230+ case uri ^? uriScheme of
231+ Just (Just (URI.unRText -> s)) -> case (mustBeHttps, s) of
232+ (False, _) -> pure ()
233+ (True, "https") -> pure ()
234+ _ -> throwM $ InvalidRoute_NotHttps uri
235+ _ -> throwM $ InvalidRoute_MissingScheme uri
236+ case uri ^. uriPath of
237+ [] -> pure ()
238+ _path -> throwM $ InvalidRoute_HasPath uri
239+ case uri ^? uriAuthority . _Right . authPort of
240+ Just (Just _port) -> throwM $ InvalidRoute_HasPort uri
241+ _ -> pure ()
242+ case uri ^? uriAuthority . _Right . authHost of
243+ Nothing -> throwM $ InvalidRoute_MissingHost uri
244+ Just sslHost -> return $ T.unpack $ URI.unRText sslHost
lib/command/src/Obelisk/Command/Nix.hs+19−5
@@ -10,10 +10,13 @@ module Obelisk.Command.Nix
1010 , Target (..)
1111 , OutLink (..)
1212 , Arg (..)
13+ , boolArg
14+ , strArg
1315 ) where
1416
1517import Control.Monad.Catch (catch, throwM)
1618import Control.Monad.IO.Class (liftIO)
19+import Data.Bool (bool)
1720import Data.Default
1821import Data.List (intercalate)
1922import Data.Monoid ((<>))
@@ -46,10 +49,21 @@ instance Default Target where
4649 , _target_attr = Nothing
4750 }
4851
49−data Arg = Arg
50− { _arg_key :: String
51− , _arg_value :: String
52− }
52+data Arg
53+ = Arg_Str String String
54+ | Arg_Expr String String
55+ deriving (Eq, Show)
56+
57+strArg :: String -> String -> Arg
58+strArg k = Arg_Str k
59+
60+boolArg :: String -> Bool -> Arg
61+boolArg k = Arg_Expr k . bool "false" "true"
62+
63+cliFromArgs :: [Arg] -> [String]
64+cliFromArgs = concatMap $ \case
65+ Arg_Str k v -> ["--argstr", k, v]
66+ Arg_Expr k v -> ["--arg", k, v]
5367
5468data NixBuildConfig = NixBuildConfig
5569 { _nixBuildConfig_target :: Target
@@ -71,7 +85,7 @@ nixBuild cfg = withSpinner' ("Running nix-build on " <> desc) (Just $ const $ "B
7185 attrArg = case attr of
7286 Nothing -> []
7387 Just a -> ["-A", a]
74− args = mconcat [["--argstr", k, v] | Arg k v <- _nixBuildConfig_args cfg]
88+ args = cliFromArgs $ _nixBuildConfig_args cfg
7589 outLink = case _nixBuildConfig_outLink cfg of
7690 OutLink_Default -> []
7791 OutLink_None -> ["--no-out-link"]
lib/https/default.nixdeleted−7
@@ -1,7 +0,0 @@
1−# DO NOT HAND-EDIT THIS FILE
2−import ((import <nixpkgs> {}).fetchFromGitHub (
3− let json = builtins.fromJSON (builtins.readFile ./github.json);
4− in { inherit (json) owner repo rev sha256;
5− private = json.private or false;
6− }
7−))
lib/https/github.jsondeleted−6
@@ -1,6 +0,0 @@
1−{
2− "owner": "obsidiansystems",
3− "repo": "obelisk-https",
4− "rev": "5bc5cd81b669a0ea1b4415a1be8fd8c5a0680f0b",
5− "sha256": "105n7pxykz02n8ilgjznssmxa8hszclg21nqiyqdr64bcrxjyxf4"
6−}
migration/6a18939264224d6ce24c7426249edfea-e66afc8befac4d76b5780a960f768c31/obelisk-handoffnew file

No changes to the content.

migration/6a18939264224d6ce24c7426249edfea-e66afc8befac4d76b5780a960f768c31/obelisk-upgradenew file+15
@@ -0,0 +1,15 @@
1+# ob deploy: HTTPs support
2+
3+Obelisk deployments now support HTTPs. You must upgrade the deployment repo
4+associated with the obelisk project as follows:
5+
6+```
7+cd /your/project's/deployment/repo
8+echo True > enable_https
9+echo admin@yoursite.com > admin_email
10+echo https://yoursite.com/ > config/common/route
11+git add ... && git commit ...
12+ob deploy push
13+```
14+
15+If you do not wish to enable HTTPs, write `False` to `enable_https` instead.