From 983ee62929037c7297e2281ea3910e94a85bead5 Mon Sep 17 00:00:00 2001 From: Joey Hess Date: Mon, 10 Apr 2017 10:52:33 -0400 Subject: reorg --- src/Propellor/Property/Bootstrap.hs | 39 ++----------------------------------- src/Propellor/Property/Chroot.hs | 33 +++++++++++++++++++++++++++++++ 2 files changed, 35 insertions(+), 37 deletions(-) (limited to 'src') diff --git a/src/Propellor/Property/Bootstrap.hs b/src/Propellor/Property/Bootstrap.hs index 5f64fd69..dc1c2e0f 100644 --- a/src/Propellor/Property/Bootstrap.hs +++ b/src/Propellor/Property/Bootstrap.hs @@ -5,12 +5,13 @@ import Propellor.Bootstrap import Propellor.Property.Chroot import Data.List -import System.Posix.Directory -- | Where a propellor repository should be bootstrapped from. data RepoSource = GitRepoUrl String | GitRepoOutsideChroot + -- ^ When used in a chroot, this clones the git repository from + -- outside the chroot. -- | Bootstraps a propellor installation into -- /usr/local/propellor/ @@ -38,10 +39,6 @@ bootstrappedFrom reposource = go `requires` clonedFrom reposource -- | Clones the propellor repeository into /usr/local/propellor/ -- --- GitRepoOutsideChroot can be used when this is used in a chroot. --- In that case, it clones the /usr/local/propellor/ from outside the --- chroot into the same path inside the chroot. --- -- If the propellor repo has already been cloned, pulls to get it -- up-to-date. clonedFrom :: RepoSource -> Property Linux @@ -82,38 +79,6 @@ clonedFrom reposource = property ("Propellor repo cloned from " ++ sourcedesc) $ GitRepoUrl s -> s GitRepoOutsideChroot -> localdir --- | Runs an action with the true localdir exposed, --- not the one bind-mounted into a chroot. The action is passed the --- path containing the contents of the localdir outside the chroot. --- --- In a chroot, this is accomplished by temporily bind mounting the localdir --- to a temp directory, to preserve access to the original bind mount. Then --- we unmount the localdir to expose the true localdir. Finally, to cleanup, --- the temp directory is bind mounted back to the localdir. -exposeTrueLocaldir :: (FilePath -> IO a) -> Propellor a -exposeTrueLocaldir a = ifM inChroot - ( liftIO $ withTmpDirIn (takeDirectory localdir) "propellor.tmp" $ \tmpdir -> - bracket_ - (movebindmount localdir tmpdir) - (movebindmount tmpdir localdir) - (a tmpdir) - , liftIO $ a localdir - ) - where - movebindmount from to = do - run "mount" [Param "--bind", File from, File to] - -- Have to lazy unmount, because the propellor process - -- is running in the localdir that it's unmounting.. - run "umount" [Param "-l", File from] - -- We were in the old localdir; move to the new one after - -- flipping the bind mounts. Otherwise, commands that try - -- to access the cwd will fail because it got umounted out - -- from under. - changeWorkingDirectory "/" - changeWorkingDirectory localdir - run cmd ps = unlessM (boolSystem cmd ps) $ - error $ "exposeTrueLocaldir failed to run " ++ show (cmd, ps) - assumeChange :: Propellor Bool -> Propellor Result assumeChange a = do ok <- a diff --git a/src/Propellor/Property/Chroot.hs b/src/Propellor/Property/Chroot.hs index 7738d97e..96c75846 100644 --- a/src/Propellor/Property/Chroot.hs +++ b/src/Propellor/Property/Chroot.hs @@ -11,6 +11,7 @@ module Propellor.Property.Chroot ( ChrootTarball(..), noServices, inChroot, + exposeTrueLocaldir, -- * Internal use provisioned', propagateChrootInfo, @@ -295,6 +296,38 @@ setInChroot h = h { hostInfo = hostInfo h `addInfo` InfoVal (InChroot True) } newtype InChroot = InChroot Bool deriving (Typeable, Show) +-- | Runs an action with the true localdir exposed, +-- not the one bind-mounted into a chroot. The action is passed the +-- path containing the contents of the localdir outside the chroot. +-- +-- In a chroot, this is accomplished by temporily bind mounting the localdir +-- to a temp directory, to preserve access to the original bind mount. Then +-- we unmount the localdir to expose the true localdir. Finally, to cleanup, +-- the temp directory is bind mounted back to the localdir. +exposeTrueLocaldir :: (FilePath -> IO a) -> Propellor a +exposeTrueLocaldir a = ifM inChroot + ( liftIO $ withTmpDirIn (takeDirectory localdir) "propellor.tmp" $ \tmpdir -> + bracket_ + (movebindmount localdir tmpdir) + (movebindmount tmpdir localdir) + (a tmpdir) + , liftIO $ a localdir + ) + where + movebindmount from to = do + run "mount" [Param "--bind", File from, File to] + -- Have to lazy unmount, because the propellor process + -- is running in the localdir that it's unmounting.. + run "umount" [Param "-l", File from] + -- We were in the old localdir; move to the new one after + -- flipping the bind mounts. Otherwise, commands that try + -- to access the cwd will fail because it got umounted out + -- from under. + changeWorkingDirectory "/" + changeWorkingDirectory localdir + run cmd ps = unlessM (boolSystem cmd ps) $ + error $ "exposeTrueLocaldir failed to run " ++ show (cmd, ps) + -- | Generates a Chroot that has all the properties of a Host. -- -- Note that it's possible to create loops using this, where a host -- cgit v1.3-2-g0d8e From 03950541b77405b8822dd2cadb47bc249a2bb5d3 Mon Sep 17 00:00:00 2001 From: Joey Hess Date: Mon, 10 Apr 2017 11:12:17 -0400 Subject: copy git configuration into chroot --- src/Propellor/Property/Bootstrap.hs | 82 +++++++++++++++++++++++-------------- src/Propellor/Property/Chroot.hs | 8 ++-- 2 files changed, 55 insertions(+), 35 deletions(-) (limited to 'src') diff --git a/src/Propellor/Property/Bootstrap.hs b/src/Propellor/Property/Bootstrap.hs index dc1c2e0f..5678a865 100644 --- a/src/Propellor/Property/Bootstrap.hs +++ b/src/Propellor/Property/Bootstrap.hs @@ -5,13 +5,14 @@ import Propellor.Bootstrap import Propellor.Property.Chroot import Data.List +import qualified Data.ByteString as B -- | Where a propellor repository should be bootstrapped from. data RepoSource = GitRepoUrl String | GitRepoOutsideChroot - -- ^ When used in a chroot, this clones the git repository from - -- outside the chroot. + -- ^ When used in a chroot, this copies the git repository from + -- outside the chroot, including its configuration. -- | Bootstraps a propellor installation into -- /usr/local/propellor/ @@ -42,42 +43,61 @@ bootstrappedFrom reposource = go `requires` clonedFrom reposource -- If the propellor repo has already been cloned, pulls to get it -- up-to-date. clonedFrom :: RepoSource -> Property Linux -clonedFrom reposource = property ("Propellor repo cloned from " ++ sourcedesc) $ do - ifM needclone - ( do - let tmpclone = localdir ++ ".tmpclone" - system <- getOS - assumeChange $ exposeTrueLocaldir $ \sysdir -> do - let originloc = case reposource of - GitRepoUrl s -> s - GitRepoOutsideChroot -> sysdir - runShellCommand $ buildShellCommand - [ installGitCommand system - , "rm -rf " ++ tmpclone - , "git clone " ++ shellEscape originloc ++ " " ++ tmpclone - , "mkdir -p " ++ localdir - -- This is done rather than deleting - -- the old localdir, because if it is bound - -- mounted from outside the chroot, deleting - -- it after unmounting in unshare will remove - -- the bind mount outside the unshare. - , "(cd " ++ tmpclone ++ " && tar c .) | (cd " ++ localdir ++ " && tar x)" - , "rm -rf " ++ tmpclone - ] - , assumeChange $ exposeTrueLocaldir $ const $ +clonedFrom reposource = case reposource of + GitRepoOutsideChroot -> go `onChange` copygitconfig + _ -> go + where + go :: Property Linux + go = property ("Propellor repo cloned from " ++ sourcedesc) $ + ifM needclone (makeclone, updateclone) + + makeclone = do + let tmpclone = localdir ++ ".tmpclone" + system <- getOS + assumeChange $ exposeTrueLocaldir $ \sysdir -> do + let originloc = case reposource of + GitRepoUrl s -> s + GitRepoOutsideChroot -> sysdir runShellCommand $ buildShellCommand - [ "cd " ++ localdir - , "git pull" + [ installGitCommand system + , "rm -rf " ++ tmpclone + , "git clone " ++ shellEscape originloc ++ " " ++ tmpclone + , "mkdir -p " ++ localdir + -- This is done rather than deleting + -- the old localdir, because if it is bound + -- mounted from outside the chroot, deleting + -- it after unmounting in unshare will remove + -- the bind mount outside the unshare. + , "(cd " ++ tmpclone ++ " && tar c .) | (cd " ++ localdir ++ " && tar x)" + , "rm -rf " ++ tmpclone ] - ) - where + + updateclone = assumeChange $ exposeTrueLocaldir $ const $ + runShellCommand $ buildShellCommand + [ "cd " ++ localdir + , "git pull" + ] + + -- Copy the git config of the repo outside the chroot into the + -- chroot. This way it has the same remote urls, and other git + -- configuration. + copygitconfig :: Property Linux + copygitconfig = property ("Propellor repo git config copied from outside the chroot") $ do + let gitconfig = localdir <> ".git" <> "config" + cfg <- liftIO $ B.readFile gitconfig + exposeTrueLocaldir $ const $ + liftIO $ B.writeFile gitconfig cfg + return MadeChange + needclone = (inChroot <&&> truelocaldirisempty) <||> (liftIO (not <$> doesDirectoryExist localdir)) + truelocaldirisempty = exposeTrueLocaldir $ const $ runShellCommand ("test ! -d " ++ localdir ++ "/.git") + sourcedesc = case reposource of GitRepoUrl s -> s - GitRepoOutsideChroot -> localdir + GitRepoOutsideChroot -> localdir ++ " outside the chroot" assumeChange :: Propellor Bool -> Propellor Result assumeChange a = do @@ -87,5 +107,5 @@ assumeChange a = do buildShellCommand :: [String] -> String buildShellCommand = intercalate "&&" . map (\c -> "(" ++ c ++ ")") -runShellCommand :: String -> IO Bool +runShellCommand :: String -> Propellor Bool runShellCommand s = liftIO $ boolSystem "sh" [ Param "-c", Param s] diff --git a/src/Propellor/Property/Chroot.hs b/src/Propellor/Property/Chroot.hs index 96c75846..5f764d47 100644 --- a/src/Propellor/Property/Chroot.hs +++ b/src/Propellor/Property/Chroot.hs @@ -304,17 +304,17 @@ newtype InChroot = InChroot Bool -- to a temp directory, to preserve access to the original bind mount. Then -- we unmount the localdir to expose the true localdir. Finally, to cleanup, -- the temp directory is bind mounted back to the localdir. -exposeTrueLocaldir :: (FilePath -> IO a) -> Propellor a +exposeTrueLocaldir :: (FilePath -> Propellor a) -> Propellor a exposeTrueLocaldir a = ifM inChroot - ( liftIO $ withTmpDirIn (takeDirectory localdir) "propellor.tmp" $ \tmpdir -> + ( withTmpDirIn (takeDirectory localdir) "propellor.tmp" $ \tmpdir -> bracket_ (movebindmount localdir tmpdir) (movebindmount tmpdir localdir) (a tmpdir) - , liftIO $ a localdir + , a localdir ) where - movebindmount from to = do + movebindmount from to = liftIO $ do run "mount" [Param "--bind", File from, File to] -- Have to lazy unmount, because the propellor process -- is running in the localdir that it's unmounting.. -- cgit v1.3-2-g0d8e From fe4b58f7db06cd59b95e73ef2a664372d0a4addd Mon Sep 17 00:00:00 2001 From: Félix Sipma Date: Thu, 27 Apr 2017 14:57:12 +0200 Subject: add Restic module --- propellor.cabal | 1 + src/Propellor/Property/Restic.hs | 204 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 205 insertions(+) create mode 100644 src/Propellor/Property/Restic.hs (limited to 'src') diff --git a/propellor.cabal b/propellor.cabal index 58651a01..292bc79d 100644 --- a/propellor.cabal +++ b/propellor.cabal @@ -136,6 +136,7 @@ Library Propellor.Property.PropellorRepo Propellor.Property.Prosody Propellor.Property.Reboot + Propellor.Property.Restic Propellor.Property.Rsync Propellor.Property.Sbuild Propellor.Property.Scheduled diff --git a/src/Propellor/Property/Restic.hs b/src/Propellor/Property/Restic.hs new file mode 100644 index 00000000..55a68324 --- /dev/null +++ b/src/Propellor/Property/Restic.hs @@ -0,0 +1,204 @@ +-- | Maintainer: Félix Sipma +-- +-- Support for the restic backup tool + +module Propellor.Property.Restic + ( ResticRepo (..) + , installed + , repoExists + , init + , restored + , backup + , KeepPolicy (..) + ) where + +import Propellor.Base hiding (init) +import Prelude hiding (init) +import qualified Propellor.Property.Apt as Apt +import qualified Propellor.Property.Cron as Cron +import qualified Propellor.Property.File as File +import Data.List (intercalate) + +type Url = String + +type ResticParam = String + +data ResticRepo + = Direct FilePath + | SFTP User HostName FilePath + | REST Url + +instance ConfigurableValue ResticRepo where + val (Direct fp) = fp + val (SFTP u h fp) = "sftp:" ++ val u ++ "@" ++ val h ++ ":" ++ fp + val (REST url) = "rest:" ++ url + +installed :: Property DebianLike +installed = withOS desc $ \w o -> case o of + (Just (System (Debian _ (Stable "jessie")) _)) -> ensureProperty w $ + Apt.installedBackport ["restic"] + _ -> ensureProperty w $ + Apt.installed ["restic"] + where + desc = "installed restic" + +repoExists :: ResticRepo -> IO Bool +repoExists repo = boolSystem "restic" + [ Param "-r" + , File (val repo) + , Param "--password-file" + , File (getPasswordFile repo) + , Param "snapshots" + ] + +passwordFileDir :: FilePath +passwordFileDir = "/etc/restic-keys" + +getPasswordFile :: ResticRepo -> FilePath +getPasswordFile repo = passwordFileDir File.configFileName (val repo) + +passwordFileConfigured :: ResticRepo -> Property (HasInfo + UnixLike) +passwordFileConfigured repo = propertyList "restic password file" $ props + & File.dirExists passwordFileDir + & File.mode passwordFileDir 0O2700 + & getPasswordFile repo `File.hasPrivContent` hostContext + +-- | Inits a new restic repository +init :: ResticRepo -> Property (HasInfo + DebianLike) +init repo = check (not <$> repoExists repo) (cmdProperty "restic" initargs) + `requires` installed + `requires` passwordFileConfigured repo + where + initargs = + [ "-r" + , val repo + , "--password-file" + , getPasswordFile repo + , "init" + ] + +-- | Restores a directory from a restic backup. +-- +-- Only does anything if the directory does not exist, or exists, +-- but is completely empty. +-- +-- The restore is performed atomically; restoring to a temp directory +-- and then moving it to the directory. +restored :: FilePath -> ResticRepo -> Property (HasInfo + DebianLike) +restored dir repo = go + `requires` installed + `requires` passwordFileConfigured repo + where + go :: Property DebianLike + go = property (dir ++ " restored by restic") $ ifM (liftIO needsRestore) + ( do + warningMessage $ dir ++ " is empty/missing; restoring from backup ..." + liftIO restore + , noChange + ) + + needsRestore = null <$> catchDefaultIO [] (dirContents dir) + + restore = withTmpDirIn (takeDirectory dir) "restic-restore" $ \tmpdir -> do + ok <- boolSystem "restic" + [ Param "-r" + , File (val repo) + , Param "--password-file" + , File (getPasswordFile repo) + , Param "restore" + , Param "latest" + , Param "--target" + , File tmpdir + ] + let restoreddir = tmpdir ++ "/" ++ dir + ifM (pure ok <&&> doesDirectoryExist restoreddir) + ( do + void $ tryIO $ removeDirectory dir + renameDirectory restoreddir dir + return MadeChange + , return FailedChange + ) + +-- | Installs a cron job that causes a given directory to be backed +-- up, by running borg with some parameters. +-- +-- If the directory does not exist, or exists but is completely empty, +-- this Property will immediately restore it from an existing backup. +-- +-- So, this property can be used to deploy a directory of content +-- to a host, while also ensuring any changes made to it get backed up. +-- For example: +-- +-- > & Restic.backup "/srv/git" +-- > (Restic.SFTP (User root) (HostName myserver) /mnt/backup/git.restic") +-- > Cron.Daily +-- > ["--exclude=/srv/git/tobeignored"] +-- > [Restic.KeepDays 7, Restic.KeepWeeks 4, Restic.KeepMonths 6, Restic.KeepYears 1] +-- +-- Since restic uses a fair amount of system resources, only one restic +-- backup job will be run at a time. Other jobs will wait their turns to +-- run. +backup :: FilePath -> ResticRepo -> Cron.Times -> [ResticParam] -> [KeepPolicy] -> Property (HasInfo + DebianLike) +backup dir repo crontimes extraargs kp = backup' dir repo crontimes extraargs kp + `requires` restored dir repo + +-- | Does a backup, but does not automatically restore. +backup' :: FilePath -> ResticRepo -> Cron.Times -> [ResticParam] -> [KeepPolicy] -> Property (HasInfo + DebianLike) +backup' dir repo crontimes extraargs kp = cronjob + `describe` desc + `requires` installed + `requires` passwordFileConfigured repo + where + desc = val repo ++ " restic backup" + cronjob = Cron.niceJob ("restic_backup" ++ dir) crontimes (User "root") "/" $ + "flock " ++ shellEscape lockfile ++ " sh -c " ++ backupcmd + lockfile = "/var/lock/propellor-restic.lock" + backupcmd = intercalate ";" $ + createCommand + : if null kp then [] else [pruneCommand] + createCommand = unwords $ + [ "restic" + , "-r" + , val repo + , "--password-file" + , getPasswordFile repo + ] + ++ map shellEscape extraargs ++ + [ "backup" + , shellEscape dir + ] + pruneCommand = unwords $ + [ "restic" + , "-r" + , val repo + , "--password-file" + , getPasswordFile repo + , "forget" + , "--prune" + ] + ++ + map keepParam kp + +-- | Constructs a ResticParam that specifies which old backup generations to +-- keep. By default, all generations are kept. However, when this parameter is +-- passed to the `backup` property, they will run restic prune to clean out +-- generations not specified here. +keepParam :: KeepPolicy -> ResticParam +keepParam (KeepLast n) = "--keep-last=" ++ val n +keepParam (KeepHours n) = "--keep-hourly=" ++ val n +keepParam (KeepDays n) = "--keep-daily=" ++ val n +keepParam (KeepWeeks n) = "--keep-weekly=" ++ val n +keepParam (KeepMonths n) = "--keep-monthly=" ++ val n +keepParam (KeepYears n) = "--keep-yearly=" ++ val n + +-- | Policy for backup generations to keep. For example, KeepDays 30 will +-- keep the latest backup for each day when a backup was made, and keep the +-- last 30 such backups. When multiple KeepPolicies are combined together, +-- backups meeting any policy are kept. See borg's man page for details. +data KeepPolicy + = KeepLast Int + | KeepHours Int + | KeepDays Int + | KeepWeeks Int + | KeepMonths Int + | KeepYears Int -- cgit v1.3-2-g0d8e From f6b2ab29f24c7399ed7ab718c541eb46bc0f24f7 Mon Sep 17 00:00:00 2001 From: Félix Sipma Date: Thu, 27 Apr 2017 19:17:34 +0200 Subject: Restic: make sure the repo exists before running restic commands --- src/Propellor/Property/Restic.hs | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) (limited to 'src') diff --git a/src/Propellor/Property/Restic.hs b/src/Propellor/Property/Restic.hs index 55a68324..668843bb 100644 --- a/src/Propellor/Property/Restic.hs +++ b/src/Propellor/Property/Restic.hs @@ -86,8 +86,7 @@ init repo = check (not <$> repoExists repo) (cmdProperty "restic" initargs) -- and then moving it to the directory. restored :: FilePath -> ResticRepo -> Property (HasInfo + DebianLike) restored dir repo = go - `requires` installed - `requires` passwordFileConfigured repo + `requires` init repo where go :: Property DebianLike go = property (dir ++ " restored by restic") $ ifM (liftIO needsRestore) @@ -146,8 +145,7 @@ backup dir repo crontimes extraargs kp = backup' dir repo crontimes extraargs kp backup' :: FilePath -> ResticRepo -> Cron.Times -> [ResticParam] -> [KeepPolicy] -> Property (HasInfo + DebianLike) backup' dir repo crontimes extraargs kp = cronjob `describe` desc - `requires` installed - `requires` passwordFileConfigured repo + `requires` init repo where desc = val repo ++ " restic backup" cronjob = Cron.niceJob ("restic_backup" ++ dir) crontimes (User "root") "/" $ -- cgit v1.3-2-g0d8e From 1b7abb5d209e4bdb66737f7fbdbc312e7802f081 Mon Sep 17 00:00:00 2001 From: Joey Hess Date: Thu, 27 Apr 2017 16:31:18 -0400 Subject: few little things --- src/Propellor/Property/Restic.hs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) (limited to 'src') diff --git a/src/Propellor/Property/Restic.hs b/src/Propellor/Property/Restic.hs index 668843bb..ef867de3 100644 --- a/src/Propellor/Property/Restic.hs +++ b/src/Propellor/Property/Restic.hs @@ -40,7 +40,7 @@ installed = withOS desc $ \w o -> case o of _ -> ensureProperty w $ Apt.installed ["restic"] where - desc = "installed restic" + desc = "installed restic" repoExists :: ResticRepo -> IO Bool repoExists repo = boolSystem "restic" @@ -119,7 +119,7 @@ restored dir repo = go ) -- | Installs a cron job that causes a given directory to be backed --- up, by running borg with some parameters. +-- up, by running restic with some parameters. -- -- If the directory does not exist, or exists but is completely empty, -- this Property will immediately restore it from an existing backup. @@ -192,7 +192,7 @@ keepParam (KeepYears n) = "--keep-yearly=" ++ val n -- | Policy for backup generations to keep. For example, KeepDays 30 will -- keep the latest backup for each day when a backup was made, and keep the -- last 30 such backups. When multiple KeepPolicies are combined together, --- backups meeting any policy are kept. See borg's man page for details. +-- backups meeting any policy are kept. See restic's man page for details. data KeepPolicy = KeepLast Int | KeepHours Int -- cgit v1.3-2-g0d8e From b06edbda0478ed57954d716f64f6870d7ae68f63 Mon Sep 17 00:00:00 2001 From: Félix Sipma Date: Fri, 28 Apr 2017 00:19:46 +0200 Subject: Restic: fix bug in shell escaping --- src/Propellor/Property/Restic.hs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) (limited to 'src') diff --git a/src/Propellor/Property/Restic.hs b/src/Propellor/Property/Restic.hs index 668843bb..02b2ead0 100644 --- a/src/Propellor/Property/Restic.hs +++ b/src/Propellor/Property/Restic.hs @@ -149,17 +149,17 @@ backup' dir repo crontimes extraargs kp = cronjob where desc = val repo ++ " restic backup" cronjob = Cron.niceJob ("restic_backup" ++ dir) crontimes (User "root") "/" $ - "flock " ++ shellEscape lockfile ++ " sh -c " ++ backupcmd + "flock " ++ shellEscape lockfile ++ " sh -c " ++ shellEscape backupcmd lockfile = "/var/lock/propellor-restic.lock" - backupcmd = intercalate ";" $ + backupcmd = intercalate " && " $ createCommand : if null kp then [] else [pruneCommand] createCommand = unwords $ [ "restic" , "-r" - , val repo + , shellEscape (val repo) , "--password-file" - , getPasswordFile repo + , shellEscape (getPasswordFile repo) ] ++ map shellEscape extraargs ++ [ "backup" @@ -168,9 +168,9 @@ backup' dir repo crontimes extraargs kp = cronjob pruneCommand = unwords $ [ "restic" , "-r" - , val repo + , shellEscape (val repo) , "--password-file" - , getPasswordFile repo + , shellEscape (getPasswordFile repo) , "forget" , "--prune" ] -- cgit v1.3-2-g0d8e