{-# LANGUAGE FlexibleContexts #-} {-# LANGUAGE LambdaCase #-} {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE RankNTypes #-} {-# LANGUAGE ScopedTypeVariables #-} -- | -- Module: BDCS.Export.Ostree -- Copyright: (c) 2017 Red Hat, Inc. -- License: LGPL -- -- Maintainer: https://github.com/weldr -- Stability: alpha -- Portability: portable -- -- Functions for exporting objects from the BDCS into an ostree repo. module BDCS.Export.Ostree(ostreeSink) where import Conduit(Conduit, Consumer, Producer, (.|), bracketP, runConduit, sourceDirectory, yield) import Control.Conditional(condM, otherwiseM, whenM) import qualified Control.Exception.Lifted as CEL import Control.Monad(void) import Control.Monad.Except(MonadError) import Control.Monad.IO.Class(MonadIO, liftIO) import Control.Monad.Logger(MonadLoggerIO, logDebugN, logInfoN) import Control.Monad.Trans(lift) import Control.Monad.Trans.Control(MonadBaseControl, liftBaseOp) import Control.Monad.Trans.Resource(MonadResource, runResourceT) import Crypto.Hash(SHA256(..), hashInitWith, hashFinalize, hashUpdate) import qualified Data.ByteString as BS (readFile) import qualified Data.Conduit.List as CL import Data.List(isPrefixOf, stripPrefix) import Data.Maybe(fromJust) import qualified Data.Text as T import System.Directory import System.FilePath((), takeDirectory, takeFileName) import System.IO.Temp(createTempDirectory) import System.Posix.Files(createSymbolicLink, fileGroup, fileMode, fileOwner, getFileStatus, readSymbolicLink) import Text.Printf(printf) import GI.Gio(File, fileNewForPath, noCancellable) import GI.OSTree import qualified BDCS.CS as CS import BDCS.DB(Files) import BDCS.Export.Directory(directorySink) import BDCS.Export.Utils(runHacks) import BDCS.Utils.Conduit(awaitWith) import BDCS.Utils.Process(callProcessLogged) import Paths_bdcs(getDataFileName) -- Disable a hint in replaceDirs that just makes thing look confusing {-# ANN ostreeSink ("HLint: ignore Use ." :: String) #-} -- | A 'Consumer' that writes objects into a provided ostree repo. A very large amount of work -- required to make the destination a valid ostree repo is also done by this function - setting up -- symlinks and directories, pruning unneeded directories, installing an initrd, building an -- RPM database, and so forth. ostreeSink :: (MonadBaseControl IO m, MonadError String m, MonadLoggerIO m, MonadResource m) => FilePath -> Consumer (Files, CS.Object) m () ostreeSink outPath = do -- While it's possible to copy objects from one OstreeRepo to another, we can't create our own objects, meaning -- we can't add the dirtree objects we would need to tie all of the files together. So to export to a new -- ostree repo, first export to a directory, then import that file to a new repo. -- -- Note that writing and importing a tar file does not work, because ostree chokes on paths with symlinks -- (e.g., /lib64/libaudit.so.1) dst_repo <- liftIO $ open outPath void $ bracketP (createTempDirectory (takeDirectory outPath) "export") removePathForcibly (\tmpDir -> do -- Run the sink to export to a directory logDebugN "Exporting to directory" directorySink tmpDir -- Add the standard hacks logDebugN "Running standard hacks" lift $ runHacks tmpDir -- Compile the locale-archive file let localeDir = tmpDir "usr" "lib" "locale" whenM (liftIO $ doesFileExist $ localeDir "locale-archive.tmpl") $ lift $ callProcessLogged "chroot" [tmpDir, "/usr/sbin/build-locale-archive"] -- Add the kernel and initramfs lift $ installKernelInitrd tmpDir -- Replace /etc/nsswitch.conf with the altfiles version logDebugN "Modifying /etc files" liftIO $ getDataFileName "data/nsswitch-altfiles.conf" >>= readFile >>= writeFile (tmpDir "etc" "nsswitch.conf") -- Remove the fstab stub liftIO $ removeFile $ tmpDir "etc" "fstab" -- Move things around how rpm-ostree wants them liftIO $ renameDirs tmpDir -- Enable some systemd service logDebugN "Enabling systemd services" doSystemd tmpDir -- Convert /var to a tmpfiles entry logDebugN "Running tmpfiles and creating symlinks" liftIO $ convertVar tmpDir -- Add more tmpfiles entries let tmpfilesDir = tmpDir "usr" "lib" "tmpfiles.d" liftIO $ getDataFileName "data/tmpfiles-ostree.conf" >>= readFile >>= writeFile (tmpfilesDir "weldr-ostree.conf") -- Replace a bunch of top-level directories with symlinks liftIO $ replaceDirs tmpDir -- Create a /sysroot directory liftIO $ createDirectory (tmpDir "sysroot") -- Replace /usr/local with a symlink for some reason liftIO $ do removePathForcibly $ tmpDir "usr" "local" createSymbolicLink "../var/usrlocal" $ tmpDir "usr" "local" -- rpm-ostree moves /var/lib/rpm to /usr/share/rpm. We don't have a rpmdb to begin -- with, so create an empty one at /usr/share/rpm. -- rpmdb treats every path as absolute rpmdbDir <- liftIO $ makeAbsolute $ tmpDir "usr" "share" "rpm" liftIO $ createDirectoryIfMissing True rpmdbDir lift $ callProcessLogged "rpmdb" ["--initdb", "--dbpath=" ++ rpmdbDir] -- import the directory as a new commit logDebugN "Storing results as a new commit" liftIO $ withTransaction dst_repo $ \r -> do f <- storeDirectory r tmpDir commit r f "Export commit" Nothing) -- Regenerate the summary, necessary for mirroring repoRegenerateSummary dst_repo Nothing noCancellable where convertVar :: FilePath -> IO () convertVar exportDir = do -- /var needs to be empty except for a couple of symlinks we add at the end -- Convert every directory and symlink we find to a tmpfiles entry. For everything -- else, warn and remove. let tmpfilesDir = exportDir "usr" "lib" "tmpfiles.d" createDirectoryIfMissing True tmpfilesDir let varDir = exportDir "var" writeFile (tmpfilesDir "weldr-var.conf") =<< unlines <$> runResourceT (runConduit $ convertToTmp "/var" varDir .| CL.consume) -- basePath is the directory we use for the paths in the tmpfiles lines (e.g., /var/lib) -- realPath is the actual path we are traversing (e.g., /tmp/export.XXXX/var/lib) convertToTmp :: MonadResource m => FilePath -> FilePath -> Producer m String convertToTmp basePath realPath = sourceDirectory realPath .| recurseAndEmit where recurseAndEmit :: MonadResource m => Conduit FilePath m String recurseAndEmit = awaitWith $ \path -> do let baseFilePath = basePath takeFileName path -- if it's a directory, recurse into it first whenM (liftIO $ doesDirectoryExist path) (convertToTmp baseFilePath path) -- Emit a tmpfiles line condM [(liftIO $ pathIsSymbolicLink path, yieldLink baseFilePath path), (liftIO $ doesDirectoryExist path, yieldDir baseFilePath path), -- If not something we can represent as a tmpfile, warn and continue (otherwiseM, liftIO $ putStrLn $ "Warning: Unable to convert " ++ baseFilePath ++ " to a tmpfile")] -- Remove it liftIO $ removePathForcibly path -- Repeat recurseAndEmit yieldLink :: MonadIO m => FilePath -> FilePath -> Producer m String yieldLink baseFilePath realFilePath = do target <- liftIO $ readSymbolicLink realFilePath yield $ printf "L %s - - - - %s" baseFilePath target yieldDir :: MonadIO m => FilePath -> FilePath -> Producer m String yieldDir baseDirPath realDirPath = do stat <- liftIO $ getFileStatus realDirPath -- coerce the stat fields into a type that implements PrintfArg let mode = fromIntegral $ fileMode stat :: Integer let userId = fromIntegral $ fileOwner stat :: Integer let groupId = fromIntegral $ fileGroup stat :: Integer yield $ printf "d %s %#o %d %d - -" baseDirPath mode userId groupId installKernelInitrd :: (MonadBaseControl IO m, MonadLoggerIO m) => FilePath -> m () installKernelInitrd exportDir = do -- The kernel and initramfs need to be named /boot/vmlinuz- -- and /boot/initramfs-, where CHECKSUM is the sha256 -- of the kernel+initramfs. let bootDir = exportDir "boot" -- Find a vmlinuz- file. kernelList <- filter ("vmlinuz-" `isPrefixOf`) <$> liftIO (listDirectory bootDir) let (kernel, kver) = case kernelList of -- Using fromJust is fine here - we've ensured they all have that -- prefix with the filter above. hd:_ -> (bootDir hd, fromJust $ stripPrefix "vmlinuz-" hd) _ -> error "No kernel found" -- Create the initramfs let initramfs = bootDir "initramfs-" ++ kver logInfoN $ "Installing kernel " `T.append` T.pack kernel logInfoN $ "Installing initrd " `T.append` T.pack initramfs withTempDirectory' exportDir "dracut" $ \tmpDir -> callProcessLogged "chroot" [exportDir, "dracut", "--add", "ostree", "--no-hostonly", "--tmpdir=/" ++ takeFileName tmpDir, "-f", "/boot/" ++ takeFileName initramfs, kver] -- Append the checksum to the filenames kernelData <- liftIO $ BS.readFile kernel initramfsData <- liftIO $ BS.readFile initramfs let ctx = hashInitWith SHA256 let update1 = hashUpdate ctx kernelData let update2 = hashUpdate update1 initramfsData let digest = show $ hashFinalize update2 liftIO $ renameFile kernel (kernel ++ "-" ++ digest) liftIO $ renameFile initramfs (initramfs ++ "-" ++ digest) -- This is like withTempDirectory from the temporary package, but without the requirement on -- MonadThrow and MonadMask. This allows logging the call to dracut like we do everything -- else without having to think about adding those constraints to quite a lot of code. withTempDirectory' :: (MonadBaseControl IO m, MonadLoggerIO m) => FilePath -> String -> (FilePath -> m a) -> m a withTempDirectory' target template = liftBaseOp $ CEL.bracket (createTempDirectory target template) (\path -> removePathForcibly path `CEL.catch` (\(_ :: CEL.SomeException) -> return ())) renameDirs :: FilePath -> IO () renameDirs exportDir = do -- ostree mandates /usr/etc, and fails if /etc also exists. -- There is an empty /usr/etc owned by filesystem, so get rid of that and move /etc to /usr/etc let etcPath = exportDir "etc" let usrEtcPath = exportDir "usr" "etc" removePathForcibly usrEtcPath renameDirectory etcPath usrEtcPath -- usr/etc/passwd and usr/etc/group are supposed to be empty (except root and wheel) -- the real ones go in /usr/lib/{passwd,group} let usrLibPath = exportDir "usr" "lib" renameFile (usrEtcPath "passwd") (usrLibPath "passwd") renameFile (usrEtcPath "group") (usrLibPath "group") writeFile (usrEtcPath "passwd") "root:x:0:0:root:/root:/bin/bash\n" writeFile (usrEtcPath "group") "root:x:0:\nwheel:x:10:\n" -- NB: rpm-ostree also requires that /var/lib/rpm be moved to /usr/share/rpm, but we don't have any -- real RPM data to move. replaceDirs :: FilePath -> IO () replaceDirs exportDir = do -- Clear out anything that's already there. -- removeDirectory will fail if not directory exists but is not empty mapM_ (\dir -> whenM (doesPathExist dir) (removeDirectory dir)) (map (exportDir ) ["home", "media", "mnt", "opt", "root", "srv", "tmp"]) -- And replace (plus one new one, /ostree) createSymbolicLink "var/home" (exportDir "home") createSymbolicLink "run/media" (exportDir "media") createSymbolicLink "var/mnt" (exportDir "mnt") createSymbolicLink "var/opt" (exportDir "opt") createSymbolicLink "sysroot/ostree" (exportDir "ostree") createSymbolicLink "var/roothome" (exportDir "root") createSymbolicLink "var/srv" (exportDir "srv") createSymbolicLink "sysroot/tmp" (exportDir "tmp") doSystemd :: MonadLoggerIO m => FilePath -> m () doSystemd exportDir = do let systemdDir = exportDir "usr" "etc" "systemd" "system" liftIO $ createDirectoryIfMissing True systemdDir -- Set the default target to multi-user liftIO $ createSymbolicLink "/usr/lib/systemd/system/multi-user.target" $ systemdDir "default.target" -- Add some services that look important liftIO $ do createDirectoryIfMissing True $ systemdDir "getty.target.wants" createDirectoryIfMissing True $ systemdDir "local-fs.target.wants" createSymbolicLink "/usr/lib/systemd/system/getty@.service" $ systemdDir "getty.target.wants" "getty@tty1.service" createSymbolicLink "/usr/lib/systemd/system/ostree-remount.service" $ systemdDir "local-fs.target.wants" "ostree-remount.service" -- Write the commit metadata object to an opened ostree repo, given the results of calling store. This -- function also requires a commit subject and optionally a commit body. The subject and body are -- visible if you use "ostree log master". Returns the checksum of the commit. commit :: IsRepo a => a -> File -> T.Text -> Maybe T.Text -> IO T.Text commit repo repoFile subject body = unsafeCastTo RepoFile repoFile >>= \root -> do -- Get the parent, which should always be whatever "master" points to. If there is no parent -- (likely because nothing has been imported into this repo before), just return Nothing. -- ostree will know what to do. parent <- parentCommit repo "master" checksum <- repoWriteCommit repo parent (Just subject) body Nothing root noCancellable repoTransactionSetRef repo Nothing "master" (Just checksum) return checksum -- Open the named ostree repo. If the repo does not already exist, it will first be created. -- It is created in Z2 mode because that can be modified without being root. open :: FilePath -> IO Repo open fp = do path <- fileNewForPath fp repo <- repoNew path doesDirectoryExist fp >>= \case True -> repoOpen repo noCancellable >> return repo False -> repoCreate repo RepoModeArchiveZ2 noCancellable >> return repo -- Given a commit, return the parent of it or Nothing if no parent exists. parentCommit :: IsRepo a => a -> T.Text -> IO (Maybe T.Text) parentCommit repo commitSum = CEL.catch (Just <$> repoResolveRev repo commitSum False) (\(_ :: CEL.SomeException) -> return Nothing) -- Same as store, but takes a path to a directory to import storeDirectory :: IsRepo a => a -> FilePath -> IO File storeDirectory repo path = do importFile <- fileNewForPath path mtree <- mutableTreeNew repoWriteDirectoryToMtree repo importFile mtree Nothing noCancellable repoWriteMtree repo mtree noCancellable -- Wrap some repo-manipulating function in a transaction, committing it if the function succeeds. -- Returns the checksum of the commit. withTransaction :: IsRepo a => a -> (a -> IO b) -> IO b withTransaction repo fn = CEL.bracket_ (repoPrepareTransaction repo noCancellable) (repoCommitTransaction repo noCancellable) (fn repo)