docker-build-cacher (empty) → 1.0
raw patch · 5 files changed
+584/−0 lines, 5 filesdep +basedep +containersdep +foldlsetup-changed
Dependencies added: base, containers, foldl, language-dockerfile, system-filepath, text, turtle
Files
- LICENSE +29/−0
- README.md +128/−0
- Setup.hs +2/−0
- app/Main.hs +398/−0
- docker-build-cacher.cabal +27/−0
+ LICENSE view
@@ -0,0 +1,29 @@+BSD 3-Clause License++Copyright (c) 2017, SeatGeek+All rights reserved.++Redistribution and use in source and binary forms, with or without+modification, are permitted provided that the following conditions are met:++* Redistributions of source code must retain the above copyright notice, this+ list of conditions and the following disclaimer.++* Redistributions in binary form must reproduce the above copyright notice,+ this list of conditions and the following disclaimer in the documentation+ and/or other materials provided with the distribution.++* Neither the name of the copyright holder nor the names of its+ contributors may be used to endorse or promote products derived from+ this software without specific prior written permission.++THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"+AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE+IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE+DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE+FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL+DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR+SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER+CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,+OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ README.md view
@@ -0,0 +1,128 @@+# Docker Build Cacher++This tool is intended to speedup multi-stage Dockerfile build times by caching the results of each of the+stages separately.++## Why?++[Multi-stage docker file](https://docs.docker.com/engine/userguide/eng-image/multistage-build/) builds are great,+but they still miss a key feature: It is not possible to carry from one build to another the statically generated+cache files once the source file in your project change. Here's an example that illustrates the issue:++Imagine you create a generic Dockerfile for building node projects++```Dockerfile+FROM nodejs++RUN apt-get install nodejs yarn++WORKDIR /app++# Whenever this image is used execute these triggers+ONBUILD ADD package.json yarn.lock .+ONBUILD RUN yarn+ONBUILD RUN yarn run dist+```++And then you call++```bash+docker build -t nodejs-build .+```++So now you can use the `nodejs-build` image in other builds, like this:++```Dockerfile+# Automatically build yarn dependencies+FROM nodejs-build as nodedeps++# Build the final container image+FROM scratch++# Copy the generated app.js from yarn run dist+COPY --from=nodedeps /app/app.js .+...+```++So far so good, we have build a pretty lean docker image that discards all the `node_modules`+folder and only keeps the final artifact. For example a bundled reactjs application.++It's also very fast to build! Since each of the steps in the Dockerfile are cached, as long as+none of the files changed.++But that's also where the problem is: Whenever `package.json` or `yarn.lock` files change, docker+will trash all the files in `node_modules` and all the cached yarn packages and will start from+scratch downloading, linking and building every single dependency.++That's far from ideal. What if we could do a change in the process so that changes to those files+do not bust the yarn cache? It turns out that we can!++## Enter docker-build-cacher++This utility overcomes the problem by providing a way to build the docker file and then cache the+intermediate stages. On subsequent builds, it will make sure that the static cache files generated+during previous builds will also be present.++The effect it has should be obvious: your builds will be consistently fast, at the cost of more disk space.++## Installation++There are binaries provided for `linux-x86_64` and MacOS, check+[the releases page](https://github.com/seatgeek/docker-build-cacher/releases) for downloads.++## Usage++`docker-build-cacher` requires the following environment variables to be present in order to correctly build+your Dockerfile:++* `APP_NAME`: The name for application you are trying to build. Usually this is just the folder name you are in.+* `GIT_BRANCH`: The name of the git branch you are building. Used to "namespace" cache results+* `DOCKER_TAG`: It will `docker build -t $DOCKER_TAG .` at some point. Let it know the image tag you want at the end.++This utility has two modes, `Build` and `Cache`. Both modes should be invoked for the cache to work:++```bash+export APP_NAME=fancyapp+export GIT_BRANCH=master+export DOCKER_TAG=fancyapp:latest++docker-build-cacher Build # This will build the docker file+docker-build-cacher Cache # This will cache each of the stage results separately+```++Additionally, `docker-build-cacher` accepts the `DOCKERFILE` env variable in case the file is not present in the+current directory:++```bash+DOCKERFILE=buildfiles/Dockerfile docker-build-cacher Build+```++At the end of the process you can call `docker images` and see that it has created `fancyapp:latest`, and if you are using+multi-stage builds, it should have created an image tag for each of the stages in your Dockerfile++## How It Works++This works by parsing the Dockerfile and extracting the `COPY` or `ADD` instructions nested inside `ONBUILD` for each of+the stages found in the file.++It will compare the source files present in such `COPY` or `ADD` instructions to check for changes. If it can detect changes,+it rewrites your Dockerfile on the fly so that the `FROM` directives in each of the stages use the locally cached images instead+of the original base image.++The effect this `FROM` swap has, is that disk state for the image is preserved between builds.+++## Building from source++Dependencies:++- [Haskell stack](https://docs.haskellstack.org/en/stable/README/#how-to-install)++Install the `stack` tool from the link above. Then `cd` to the root folder of this repo and execute:++```sh+stack setup+stack install+```++If it is the first time, it will take *a lot* of time. Don't worry, it's only once you need to pay this price.
+ Setup.hs view
@@ -0,0 +1,2 @@+import Distribution.Simple+main = defaultMain
+ app/Main.hs view
@@ -0,0 +1,398 @@+#!/usr/bin/env stack+{- stack+ runghc+ --resolver lts-8.18+ --install-ghc+ --package turtle+ --package language-dockerfile+ --package containers+-}+{-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE RecordWildCards #-}+{-# LANGUAGE NamedFieldPuns #-}++module Main where++import qualified Control.Foldl as Fold+import Control.Monad (guard, when)+import Data.Either (rights)+import qualified Data.Map.Strict as Map+import Data.Maybe (catMaybes, isJust, mapMaybe)+import qualified Data.Text as Text+import Data.Text (Text)+import Filesystem.Path (FilePath)+import Language.Dockerfile hiding (Tag)+import Prelude hiding (FilePath)+import Turtle++{- Glossary:+ - InstructionPos: in the AST for a docker file, each of the lines are described with the type InstructionPos+ - Dockerfile: A list of InstructionPos+ - stage: Each of the FROM instructions in a Dockerfile+ - cache buster: Files inside a docker image that can be compared with files locally under the same path+-}+newtype App =+ App Text+ deriving (Show)++newtype Branch =+ Branch Text+ deriving (Show)++newtype Tag =+ Tag Text+ deriving (Show, Eq)++newtype SourcePath =+ SourcePath FilePath+ deriving (Show, Eq)++newtype TargetPath =+ TargetPath FilePath+ deriving (Show, Eq)++-- | A Stage is one of the FROM directives ina dockerfile+--+data Stage = Stage+ { stageName :: Text -- The image name+ , stageTag :: Tag -- The image tag+ , stagePos :: Linenumber -- Where in the docker file this line is found+ , stageAlias :: Text -- The alias used for building+ , directives :: Dockerfile -- Dockerfile is an alias for [InstructionPos]+ , alreadyCached :: Bool -- Whether or not we have this stage built separately on the host+ } deriving (Show, Eq)++data InspectedStage+ = NotCached Stage -- When the image has not yet been built separetely+ | Cached { stage :: Stage -- When the image was built and tagged as a separate image+ , cacheBusters :: [(SourcePath, TargetPath)] -- List of files that are able to bust the cache+ }+ deriving (Show)++-- | This script has 2 modes. One for building the Dockerfile, and another for caching its stages+data Mode+ = Build+ | Cache+ deriving (Read, Show)++-- | Describes the arguments this script takes from the command line+parser :: Parser Mode+parser = argRead "mode" "Whether to build or to cache (options: Build | Cache)"++main = do+ mode <- options "Builds a docker file and caches its stages" parser -- Parse the CLI arguments as a Mode+ app <- App <$> needEnv "APP_NAME" -- Get the APP environment variable and then wrap it in App+ branch <- Branch <$> needEnv "GIT_BRANCH"+ maybeFile <-+ do f <- need "DOCKERFILE" -- Get the dockerfile path if any+ return (fmap fromText f) -- And transform it to a FilePath+ --+ -- if DOCKERFILE is not present, we assume is in the current directory+ currentDirectory <- pwd+ let Just file = maybeFile <|> Just (currentDirectory </> "Dockerfile")+ --+ -- Now we try to parse the dockefile+ dockerFile <- parseFile (Text.unpack (format fp file)) -- Convert the dockerfile to an AST+ case dockerFile of+ Left message -> error ("There was an error parsing the docker file: " <> show message)+ Right ast ->+ case mode of+ Cache -> sh (cacheBuild app branch ast)+ Build -> do+ tag <- Tag <$> needEnv "DOCKER_TAG"+ sh (build app branch tag ast)++-- | Builds the provided Dockerfile. If it is a multi-stage build, check if those stages are already cached+-- and change the dockerfile to take advantage of that.+build :: App -> Branch -> Tag -> Dockerfile -> Shell ()+build app branch tag ast = do+ changedStages <- getChangedStages app branch ast -- Inspect the dockerfile and return the stages that got their cache invalidated+ echo "I'll start building now the main Dockerfile"+ let bustedStages = replaceStages (filter alreadyCached changedStages) ast -- We replace the busted stages with cached primed ones+ status <- buildDockerfile app tag bustedStages -- Build the main docker file with the maybe changed stages+ case status of+ ExitSuccess -> do+ echo+ "I built the main dockerfile without a problem. Now call this same script with the `Cache` mode"+ ExitFailure _ -> do+ die "Boo, I could not build the project"++-- | One a dockefile is built, we can extrac each of the stages separately and then tag them, so the cache+-- can be retreived at a later point.+cacheBuild :: App -> Branch -> Dockerfile -> Shell ()+cacheBuild app branch ast = do+ changedStages <- getChangedStages app branch ast+ when (changedStages /= []) $ do+ echo "--> Let's make the cache great again"+ mapM_ (buildAssetStage app) changedStages -- Build each of the stages so they can be reused later++-- | Returns a list of stages which needs to either be built separately or that did not have their cached busted+-- by the introduction of new code.+getChangedStages :: App -> Branch -> Dockerfile -> Shell [Stage]+getChangedStages app branch ast = do+ let mainFile = last (getStages ast) -- Parse all the FROM instructions in the dockerfile+ assetStages = takeWhile (/= mainFile) (getStages ast) -- Filter out the main FROM at the end+ stages = mapMaybe (toStage app branch) assetStages -- Convert to Stage records, filter out errors+ when (length assetStages > length stages) showAliasesWarning+ -- Time to get all the stages having changed cache files+ bustCacheStages <- fold (getAlreadyCached stages >>= inspectCache >>= shouldBustCache) Fold.list+ return (catMaybes bustCacheStages) -- Remove the stages that did not actually change+ where+ showAliasesWarning = do+ echo "::::::WARNING::::::"+ echo "I found some FROM directives in the dockerfile that did not have an `as` alias"+ echo "I'm not smart enough to build multi-stage docker files without aliases."+ echo "While this is safe to do, you will get no cache benefits"+ echo ""+ echo "Please always write your FROM directives as `FROM image:tag as myalias`"++-- | Check whehther or not the tag exists for each of the passed stages+-- and return only those that already exist.+getAlreadyCached :: [Stage] -> Shell Stage+getAlreadyCached stages = do+ echo "--> I'm checking whether or not the stage exists as a docker image already"+ stage@(Stage {stageTag}) <- select stages -- foreach stages as stage+ printLn ("----> Looking for image " %s) (taggedBuild stageTag)+ existent <-+ fold+ (inproc "docker" ["image", "ls", taggedBuild stageTag, "--format", "{{.Repository}}"] empty)+ Fold.list -- Get the output of the command as a list of lines+ if existent == mempty+ then do+ echo "------> It does not exist, so I will need to build it myself later"+ return (stage {alreadyCached = False})+ else do+ echo "------> It already exists, so I will then check if the cache files changed"+ return (stage {alreadyCached = True})++-- | This will inspect how an image was build and extrack the ONBUILD directives. If any of those+-- instructions are copying or adding files to the build, they are considered "cache busters".+inspectCache :: Stage -> Shell InspectedStage+inspectCache stage@(Stage {..}) = do+ case alreadyCached of+ False -> return (NotCached stage)+ True -> do+ history <- imageHistory (Tag (taggedBuild stageTag))+ let onBuildLines = extractOnBuild history+ workdir = extractWorkdir history+ parsedDirectivesWithErrors = fmap (parseString . Text.unpack) onBuildLines -- Parse each of the lines+ parsedDirectives = (getFirst . rights) parsedDirectivesWithErrors -- We only keep the good lines+ cacheBusters = extractCachePaths workdir parsedDirectives+ return (Cached {..})+ where+ extractCachePaths workdir dir =+ let filePairs = concat (mapMaybe doExtract dir) -- Get the (source, target) pairs of files copied+ in fmap (prependWorkdir workdir) filePairs -- Some target paths need to have the WORKDIR prepended+ --+ -- | Prepend a given target dir to the target path+ prependWorkdir workdir (source, TargetPath target) =+ if relative target -- If the target path is relative, we need to prepend the workdir+ then (source, TargetPath (collapse (workdir </> target))) -- Remove the ./ prefix and prepend workdir+ else (source, TargetPath target)+ --+ -- | COPY allows multiple paths in the same line, we need to convert each to a separate path+ doExtract (InstructionPos (Copy fr t) _ _) =+ let target:allSources = (reverse . Text.words . Text.pack) t -- Make sure we get all the files in COPY+ sourcePaths = fmap toSource allSources+ in Just (zip sourcePaths (repeat (toTarget target)))+ --+ -- | This case is simpler, we only need to convert the source and target from ADD+ doExtract (InstructionPos (Add fr t) _ _) =+ Just [((toSource . Text.pack) fr, (toTarget . Text.pack) t)]+ doExtract _ = Nothing+ getFirst (first:_) = first+ getFirst [] = []++-- | Here check each of the cache buster from the image and compare them with those we have locally,+-- if the files do not match, then we return the stage back as a result, otherwise return Nothing.+shouldBustCache :: InspectedStage -> Shell (Maybe Stage)+shouldBustCache (NotCached stage) = return (Just stage)+shouldBustCache Cached {..} = do+ printLn ("----> Checking cache buster files for stage " %s) (stageName stage)+ containerId <- inproc "docker" ["create", (taggedBuild (stageTag stage))] empty+ -- Get the cache buster files that have changed since last time+ hasChanged <- fold (mfilter isJust (checkFileChanged containerId cacheBusters)) Fold.head+ if isJust hasChanged+ then do+ printLn ("----> The stage " %s % " changed") (stageName stage)+ return (Just stage)+ else do+ printLn ("----> The stage " %s % " did not change") (stageName stage)+ return Nothing+ where+ checkFileChanged containerId files = do+ (SourcePath file, TargetPath targetDir) <- select files+ printLn ("------> Checking file '" %fp % "' in directory " %fp) file targetDir+ currentDirectory <- pwd+ tempFile <- mktempfile currentDirectory "comp"+ let targetFile = targetDir </> file+ status <-+ proc "docker" ["cp", format (l % ":" %fp) containerId targetFile, format fp tempFile] empty+ guard (status == ExitSuccess)+ local <- liftIO (readTextFile file)+ remote <- liftIO (readTextFile tempFile)+ if local == remote+ then return Nothing+ else return (Just file)++-- | The goal is to create a temporary dockefile in this same folder with the contents+-- if the stage variable, call docker build with the generated file and tag the image+-- so we can find it later.+buildAssetStage :: App -> Stage -> Shell ()+buildAssetStage app Stage {..} = do+ printLn ("\n--> Building asset stage " %s % " at line " %d) stageName stagePos+ let filteredDirectives = filter isFrom directives+ status <- buildDockerfile app stageTag filteredDirectives -- Only build the FROM+ guard (status == ExitSuccess) -- Break if previous command failed+ history <- imageHistory stageTag -- Get the commands used to build the docker image+ newDockerfile <- createDockerfile stageTag (extractOnBuild history) -- Append the ONBUILD lines to the new file+ finalStatus <- buildDockerfile app (Tag (taggedBuild stageTag)) newDockerfile -- Now build it+ guard (finalStatus == ExitSuccess) -- Stop here if previous command failed+ echo ""+ echo "--> I have tagged a cache container that I can use next time to speed builds!"+ where+ isFrom (InstructionPos (From _) _ _) = True+ isFrom _ = False++-- | Simply call docker build for the passed arguments+buildDockerfile :: App -> Tag -> Dockerfile -> Shell ExitCode+buildDockerfile (App app) (Tag tag) directives = do+ currentDirectory <- pwd+ tmpFile <- mktempfile currentDirectory "Dockerfile."+ liftIO (writeTextFile tmpFile (Text.pack (prettyPrint directives))) -- Put the Dockerfile contents in the tmp file+ proc+ "docker"+ ["build", "--build-arg", "APP_NAME=" <> app, "-f", format fp tmpFile, "-t", tag, "."]+ empty -- Build the generated dockerfile++-- | Given a list of instructions, build a dockerfile where the tag is the FROM for the file and+-- the list of instructions are wrapped with ONBUILD+createDockerfile :: Tag -> [Text] -> Shell Dockerfile+createDockerfile (Tag tag) onBuildLines = do+ let eitherDirectives = map (parseString . Text.unpack) onBuildLines+ file =+ toDockerfile $ do+ from (tagged (Text.unpack tag) "latest")+ mapM (onBuildRaw . toInstruction) (rights eitherDirectives) -- Append each of the ONBUILD instructions+ return file+ where+ toInstruction [(InstructionPos inst _ _)] = inst+ toInstruction _ = error "This is not possible"++-- | Extracts the list of instructions appearing in ONBUILD for a given docker history for a tag+extractOnBuild :: [Line] -> [Text]+extractOnBuild lines = doExtract lines+ where+ onBuildPrefix = "/bin/sh -c #(nop) ONBUILD "+ --+ -- | First get the ONBUILD lines then strip the common prefix out of each.+ -- Arguments flow from right to left in the functions chain+ doExtract = mapMaybe (Text.stripPrefix onBuildPrefix . lineToText) . findOnBuildLines+ --+ -- | First drop the lines not starting with ONBUILD, then take only those.+ -- Arguments flow from right to left in the functions chain+ findOnBuildLines = takeWhile isOnBuild . dropWhile (not . isOnBuild)+ isOnBuild line = Text.isPrefixOf onBuildPrefix (lineToText line)++extractWorkdir :: [Line] -> FilePath+extractWorkdir lines =+ case reverse (doExtract lines) of+ [] -> fromText "/"+ lastWorkdir:_ -> fromText lastWorkdir -- We are on;y intereted in the latest WORKDIR declared+ where+ onBuildPrefix = "/bin/sh -c #(nop) ONBUILD " -- Curiously, ONBUILD is lead by 2 spaces+ workdirPrefix = "/bin/sh -c #(nop) WORKDIR " -- Whereas WORKDIR only by one space+ --+ -- | First find all relevant lines, then kepp the lines starting with the workdir prefix+ doExtract = mapMaybe (Text.stripPrefix workdirPrefix . lineToText) . findRelevantLines+ --+ -- | Take lines until a ONBUILD is found+ -- Arguments flow from right to left in the functions chain+ findRelevantLines = takeWhile (not . isOnBuild)+ isOnBuild line = Text.isPrefixOf onBuildPrefix (lineToText line)++-- | Calls docker history for the given image name and returns the output as a list+imageHistory :: Tag -> Shell [Line]+imageHistory (Tag tag) = do+ printLn ("----> Checking the docker image history for " %s) tag+ out <- fold fetchHistory Fold.list -- Buffer all the output of the imageHistory shell+ return (reverse out) -- The history comes in reverse order, sort it naturally+ where+ fetchHistory =+ inproc "docker" ["history", "--no-trunc", "--format", "{{.CreatedBy}}", tag] empty++--+-- | Returns a list of directives grouped by the appeareance of the FROM directive+-- This will return the group of all stages found in the Dockerfile+getStages :: Dockerfile -> [Dockerfile]+getStages ast = filter startsWithFROM (group ast [])+ where+ group [] acc = reverse acc -- End of recursion+ group (directive@(InstructionPos (From _) _ _):rest) acc = group rest ([directive] : acc) -- Append a new group+ group (directive:rest) [] = group rest [[directive]] -- Create a new group+ group (directive:rest) (current:prev) = group rest ((current ++ [directive]) : prev) -- Continue the currently open group+ --+ -- | Returns true if the first element in the list is a FROM directive+ startsWithFROM ((InstructionPos (From _) _ _):_) = True+ startsWithFROM _ = False++-- | Converts a list of instructions into a Stage record+toStage :: App -> Branch -> Dockerfile -> Maybe Stage+toStage (App app) (Branch branch) directives = do+ (stageName, stagePos, stageAlias) <- getStageInfo directives -- If getStageInfo returns Nothing, skip the rest+ let sanitized = sanitize stageName+ stageTag = Tag (app <> "__branch__" <> branch <> "__stage__" <> sanitized)+ alreadyCached = False+ return Stage {..}+ where+ getStageInfo :: [InstructionPos] -> Maybe (Text, Linenumber, Text)+ getStageInfo ((InstructionPos (From (TaggedImage name tag)) _ pos):_) =+ if Text.isInfixOf " as " (Text.pack tag) -- Make sure the FROM is aliased+ then Just (Text.pack name, pos, parseAlias tag)+ else Nothing+ getStageInfo _ = Nothing+ --+ -- | Makes a string safe to use it as a file name+ sanitize = Text.replace "/" "-" . Text.replace ":" "-"++-- | Extracts the stage alias out of the FROM directive+parseAlias :: String -> Text+parseAlias = Text.strip . Text.replace "as " "" . snd . Text.breakOn " as " . Text.pack++taggedBuild :: Tag -> Text+taggedBuild (Tag tag) = tag <> "-build"++-- | Given a list of stages and the AST for a Dockerfile, replace all the FROM instructions+-- with their corresponding images as described in the Stage record.+replaceStages :: [Stage] -> Dockerfile -> Dockerfile+replaceStages stages dockerLines = fmap replaceStage dockerLines+ where+ stagesMap = Map.fromList (map createStagePairs stages)+ createStagePairs stage@(Stage {..}) = (stageAlias, stage)+ --+ -- | Find whehter or not we have extracted a stage with the same alias+ -- If we did, then replace the FROM directive with our own version+ replaceStage directive@(InstructionPos (From (TaggedImage _ tag)) file pos) =+ case Map.lookup (parseAlias tag) stagesMap of+ Nothing -> directive+ Just Stage {stageTag, stageAlias} ->+ InstructionPos+ (From (TaggedImage (Text.unpack (taggedBuild stageTag)) (formatAlias stageAlias)))+ file+ pos+ replaceStage directive = directive+ formatAlias alias = "latest as " <> Text.unpack alias++printLn message = printf (message % "\n")++-- | Transforms a text to SourcePath+toSource = SourcePath . fromText++-- | Transforms a text to TargetPath+toTarget = TargetPath . fromText++needEnv varName = do+ value <- need varName+ case value of+ Nothing -> error ("I was expecting the " <> show varName <> " env var to be present.")+ Just val -> return val
+ docker-build-cacher.cabal view
@@ -0,0 +1,27 @@+name: docker-build-cacher+version: 1.0+synopsis: Builds a services with docker and caches all of its intermediate stages+description: A CLI tool to speed up multi-stage docker file builds by caching intermediate+ stages separately, so the can be used in successive builds.+license: BSD3+license-file: LICENSE+author: Jose Lorenzo Rodriguez+maintainer: lorenzo@seatgeek.com+copyright: 2017 Seatgeek+category: Operations+build-type: Simple+extra-source-files: README.md+cabal-version: >=1.10++executable docker-build-cacher+ hs-source-dirs: app+ main-is: Main.hs+ ghc-options: -threaded -rtsopts -with-rtsopts=-N+ build-depends: base >= 2 && < 4+ , turtle+ , language-dockerfile+ , containers+ , foldl+ , text+ , system-filepath+ default-language: Haskell2010