Creating, updating, and invoking AWS Lambda Functions in Haskell
Now that we’ve figured out how to compile our bootstrap
binary, let’s wrap it all up into a single Haskell module, that can help us perform the following tasks from right within our GHCi session and without having to juggle between various tools (very important during rapid development):
- compile our
bootstrap
binary - prepare the ZIP file
- create or update the Lambda Function
- invoke the Lambda Function
Preliminaries
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE NamedFieldPuns #-}
{-# LANGUAGE PartialTypeSignatures #-}
module ManagingHaskellLambdaFunctions where
import ManagingLambdaFunctions (getAwsEnv)
import Network.AWS as AWS
import Network.AWS.Lambda.ListFunctions as AWS
import Network.AWS.Lambda.Types as AWS
import Network.AWS.Lambda.UpdateFunctionConfiguration as AWS
import Network.AWS.Lambda.CreateFunction as AWS
import Network.AWS.Lambda.Invoke as AWS
import Network.AWS.Lambda.UpdateFunctionCode as AWS
import Control.Lens
import qualified Data.ByteString.Lazy as BSL
import qualified Data.ByteString as BS
import Control.Monad.IO.Class
import System.Environment (getEnv)
import Data.String.Conv (toS)
import System.Posix.Syslog.TCP as Syslog
import Data.Aeson as Aeson
import Control.Monad (forM_)
import Text.Printf(printf)
import Data.Maybe (fromMaybe, maybe)
import qualified Data.Text as T
import Data.Text (Text)
import Network.AWS.Data.Text (toText)
import Codec.Archive.Zip as Zip
import qualified Data.List as DL
import System.Process.Typed as Process
import System.Exit (ExitCode(..))
import Data.Bits
import System.Directory (createDirectoryIfMissing, listDirectory)
import System.FilePath ((</>))
import System.Posix.Files as Unix (getFileStatus, fileMode)
import Debug.Trace
import Control.Monad (forM)
import GHC.Exts (toList)
--
-- NOTE: This import is coming from the `lambda-function` package
--
import HandlerTypes
Compiling the Lambda Function
compileBootstrap :: IO FilePath
compileBootstrap = do
(Process.runProcess (Process.shell shellCmd)) >>= \case
ExitFailure c -> Prelude.error $ "Docker shell command failed with a failure exit code: " <> show c
ExitSuccess -> pure "/tmp/output"
where
outputDir = "/root/output"
shellCmd = dockerBuild <> " && " <> copyOutput
dockerBuild = "docker build -f Dockerfile -t haskell-lambda-runtime --build-arg OUTPUT_DIR=" <> outputDir <> " lambda-function"
copyOutput =
"docker cp $(docker create haskell-lambda-runtime:latest):" <> outputDir <> " /tmp"
Preparing the ZIP file
Note the difference between the prepareZipFile
function given below, and the createZipFile
function we used previousy. For the bootstrap
binary and the libraries in lib/
to work on AWS Lambda, we need them to have have executable file-permissions. Unfortunately the zip
library doesn’t make this process as easy as I’d like it to. (Related issues - #64 and #66)
prepareZipFile :: FilePath -> IO FilePath
prepareZipFile dir = do
Zip.createArchive zipFileName $ do
Zip.packDirRecur Zip.Deflate createSelector dir
Zip.commit
x <- toList <$> Zip.getEntries
forM x $ \(es, _) -> Zip.setExternalFileAttrs ((0x100000 .|. 0o0755) `shiftL` 16) es
pure zipFileName
where
zipFileName = "/tmp/upload.zip"
createSelector fpath = do
s <- Zip.mkEntrySelector fpath
pure s
“Upserting” the Lambda Function
This function is not very different from the version we wrote earlier
upsertLambdaFunctions :: Text
-> Text
-> FilePath
-> IO AWS.FunctionConfiguration
upsertLambdaFunctions fnName fnHandler zipFileName = do
env <- getAwsEnv
zipFileContents <- BS.readFile zipFileName
runResourceT $ runAWS env $ do
fns <- fmap (view lfrsFunctions) (send listFunctions)
case DL.find (\fn -> fn ^. fcFunctionName == Just fnName ) fns of
Nothing ->
send $ createFunction fnName fnRuntime fnRole fnHandler (functionCode & fcZipFile ?~ zipFileContents)
Just _ -> do
send $ (updateFunctionCode fnName) & uZipFile ?~ zipFileContents
send $ (updateFunctionConfiguration fnName)
& ufcRuntime ?~ fnRuntime
& ufcRole ?~ fnRole
& ufcHandler ?~ fnHandler
where
fnRuntime = Provided
fnRole = "arn:aws:iam::708038027599:role/firstFunctionRole"
Invoking the Lambda Function without involving API Gateway
Note the difference between this function and the version that we had written earlier. Thanks to the module exposed by the lambda-function, instead of passing arguments around as stringified JSONs, we’ve been able to use appropriate Haskell types for them instead. In fact, even the return value is being parsed into an appropriate Haskell type.
However, this leads to a problem. There is nothing in this function’s code that indicates what the result
type should be apart from saying that it should have an instance of the FromJSON
type-class. There are many types that have an instance of the FromJSON
type-class. So, what type does this function actually return?
Again, this is an unfortunate implication of Haskell’s type-system. There is no easy/built-in way in Haskell that allows us to determine the result
type based on the Lambda Function’s name (which is a string literal). Therefore, every time you use this function, you will have to apply a type-annotation at the call-site to tell the compiler what type of result
you’re expecting. Sometimes based on what you’re doing with the return value of this function, the compiler will be able to infer what the result
type is. If not, you’ll get an “ambiguous type” error, and will be forced to provide the type-annotation.
invokeHaskellLambdaFunction :: (ToJSON args, FromJSON result)
=> Text
-> args
-> IO (Either String result)
invokeHaskellLambdaFunction fnName args = do
env <- getAwsEnv
result <- runResourceT $ runAWS env $ do
send $ invoke fnName (toS $ Aeson.encode args)
pure $ case (result ^. irsStatusCode) of
200 -> maybe (Left "No result") (Aeson.eitherDecode . toS) (result ^. irsPayload)
_ -> Left $ "Non-200 response: " <> show (result ^. irsFunctionError)
Now you should be able to continiously update your Lambda Function without leaving your GHCi session (notice the type annotations):
ghci> compileBootstrap >>= prepareZipFile >>= upsertLambdaFunction "myFunc1" "handler1"
ghci> x :: Either String Handler1Res <- invokeHaskellLambdaFunction "handler" (Handler1Req 10 20)
Right (Handler1Res{intResult=30})