packages feed

debian-1.0: Linspire/Debian/OSImage.hs

-- |Module to handle Debian OS images, AKA build environments or change roots.
--
-- Author: David Fox <david.fox@linspire.com>
module Linspire.Debian.OSImage
    (OSImage,		-- (export abstract to assure consistency)
     EnvRoot(EnvRoot),
     -- * Creation
     prepareEnv,	-- [Style] -> EnvRoot -> DistroCache -> Maybe Repository -> Bool -> Bool -> IO OSImage
     syncEnv,		--  [Style] -> OSImage -> OSImage -> IO TimeDiff
     chrootEnv,		-- OSImage -> EnvRoot -> OSImage
     -- * Modification
     updateEnv,		-- [Style] -> OSImage -> IO OSImage
     neuterEnv,		-- [Style] -> OSImage -> IO OSImage
     restoreEnv,	-- OSImage -> IO OSImage
     -- * Queries
     osRoot,		-- OSImage -> EnvRoot
     osDistro,
     osSources,		-- OSImage -> [DebSource]
     osArch,		-- OSImage -> String
     osLocalRepository,	-- OSImage -> Maybe Repository
     getSourceInfo,	-- [Style] -> OSImage -> [PkgName] -> IO Control
     getBinaryInfo,	-- [Style] -> OSImage -> [PkgName] -> IO Control
     getAvailable,	-- [Style] -> OSImage -> [String] -> IO [Paragraph]
     buildEssential,	-- OSImage -> Bool -> IO Relations
     buildArch,		-- EnvRoot -> IO String
     -- * Removal
     removeEnv,		-- OSImage -> IO ()
     -- * Helper
     chrootPool		-- DistroCache -> [DebSource]
     ) where

import Control.Exception
import Data.List
import qualified Data.Map as Map
import System.Cmd
import System.Directory
import System.Exit
import System.IO
import System.Posix.Files
import System.Process
import Text.Regex

import Linspire.Unix.Directory as Unix
import Linspire.Unix.FilePath
import Linspire.Debian.Control
import Linspire.Debian.DistroCache as DistroCache
import Linspire.Debian.Relation
import Linspire.Debian.Repository as Repository
import Linspire.Debian.SourcesList
import Linspire.Debian.Version
import Linspire.Unix.Misc
import Linspire.Unix.Progress as Progress

-- |This type represents an OS image located at root built from a
-- particular distro using a particular architecture.  If a Repository
-- argument is given, that repository will be copied into the
-- environment and kept in sync, and lines will be added to
-- sources.list to point to it.
data OSImage = OS EnvRoot DistroCache Architecture (Maybe Repository)
type Architecture = String

-- |The root directory of an OS image.
data EnvRoot = EnvRoot FilePath

instance Show EnvRoot where
    show (EnvRoot path) = path

-- |A directory inside of an OS image.
data EnvPath = EnvPath EnvRoot FilePath

instance Show EnvPath where
    show (EnvPath root path) = show root ++ path

-- |Create or update an image.  FIXME: Make sure there is no
-- ".incomplete" flag next to the directory
prepareEnv :: [Style] -> EnvRoot -> DistroCache -> Maybe Repository -> Bool -> Bool -> IO OSImage
prepareEnv style root distro repo flush pgp =
    do
      exists <- doesFileExist (show root ++ "/etc/apt/sources.list")
      case flush || not exists of
        True ->          
            do
              Unix.removeRecursiveSafely (show root)
              buildEnv failStyle root distro repo pgp >>=
                       neuterEnv failStyle >>=
                       syncPool style
        False ->
            do
              let os = OS root distro "i386" repo
              try (updateEnv style os) >>=
                     -- At one point I removed and re-created the
                     -- environment if anything went wrong trying to update
                     -- it.  This is often the wrong thing to do, since it
                     -- might be caused by a glitch in networking.
                     either (\ e -> error (updateError root e)) return
    where
      failStyle = Progress.removeStyle (Start "") style
      updateError root e = ("OSImage.updateEnv failed: " ++ show e ++
                            "\n  (You may need to remove " ++ show root ++ ")")

prepareDevs :: FilePath -> IO ()
prepareDevs root = do
  mapM_ prepareDev 
            ([(root ++ "/dev/null", "c", 1, 3),
              (root ++ "/dev/zero", "c", 1, 5),
              (root ++ "/dev/full", "c", 1, 7),
              (root ++ "/dev/console", "c", 5, 1),
              (root ++ "/dev/random", "c", 1, 8),
              (root ++ "/dev/urandom", "c", 1, 9)] ++
             (map (\ n -> (root ++ "/dev/loop" ++ show n, "b", 7, n)) [0..7]) ++
             (map (\ n -> (root ++ "/dev/loop/" ++ show n, "b", 7, n)) [0..7]))
  where
    prepareDev (path, typ, major, minor) = do
                     createDirectoryIfMissing True (fst (splitFileName path))
                     let cmd = "mknod " ++ path ++ " " ++ typ ++ " " ++ show major ++ " " ++ show minor
                     exists <- doesFileExist path
                     if not exists then
                         system cmd else
                         return ExitSuccess

-- Create a new clean build environment in root.clean
-- FIXME: create an ".incomplete" flag and remove it when build-env succeeds
buildEnv :: [Style] -> EnvRoot -> DistroCache -> Maybe Repository -> Bool -> IO OSImage
buildEnv style root distro repo pgp =
    do
      let extra = ["makedev", "build-essential"] ++ (if pgp then ["pgp"] else [])
      -- We can't create the environment if the sources.list has any
      -- file:// URIs because they can't yet be visible inside the
      -- environment.  So we grep them out, create the environment, and
      -- then add them back in.
      systemTask buildStyle ("build-env -o " ++ show root ++
                             " -s " ++ DistroCache.sourcesPath distro ++
                             " with '" ++ consperse " " extra ++ "'")
      let os = OS root distro "i386" repo
      let sourceListText = osSources os
      -- ePut ("writeFile2 " ++ (show root ++ "/etc/apt/sources.list") ++ "\n" ++ (formatSources sourceListText))
      writeFile (show root ++ "/etc/apt/sources.list") (consperse "\n" (map show sourceListText) ++ "\n")
      updateEnv style os
    where
      buildStyle = setStyles [Start ("Creating clean build environment (" ++ DistroCache.dist distro ++ ")"),
                              Error "Could not create build environment."] style

-- |Update an existing build environment - run apt-get update
-- and dist-upgrade.
updateEnv :: [Style] -> OSImage -> IO OSImage
updateEnv style os@(OS root _ _ _) =
    do
      verifySources os
      prepareDevs (show root)
      syncPool style os
      syncSSH
      return os
    where
      verifySources os =
          do
            let correct = osSources os
            let sourcesPath = show root ++ "/etc/apt/sources.list"
            installed <- readFile sourcesPath >>= return . parseSourcesList
            if correct /= installed then do
                  ePut ("Sources in " ++ sourcesPath ++ " don't match configuration for " ++ (show . dist . osDistro) os ++ ":\n\n" ++
                        consperse "\n" (map show correct))
                  error ("Sources.list mismatch for " ++ (show . dist . osDistro) os) else
                return ()            
      syncSSH = systemTask syncSSHStyle ("rsync -aHxSpDt --delete ~/.ssh/ " ++ show root ++ "/root/.ssh && " ++
                                            "chown -R root.root " ++ show root ++ "/root/.ssh")
      syncSSHStyle = addStyles [Start "Copying SSH parameters",
                                Error "Could copy SSH parameters", Output Dots] style

-- These are the sources.list lines for the local pool when viewed
-- from inside the changeroot.
chrootPool :: DistroCache -> [DebSource]
chrootPool distro = DistroCache.localPool distro "/work/localpool"

chrootEnv :: OSImage -> EnvRoot -> OSImage
chrootEnv (OS _ a b c) dst = (OS dst a b c)

-- Sync the environment from the clean copy.  All this does besides
-- performing the proper rsync command is to make sure the destination
-- directory exists, otherwise rsync will fail.  Not sure why the 'work'
-- subdir is appended.  There must have been a reason at one point.
syncEnv :: [Style] -> OSImage -> OSImage -> IO OSImage
syncEnv style src dst =
    do
      createDirectoryIfMissing True (show (osRoot dst) ++ "/work")
      systemTask (setStyles [Start "Copying clean build environment",
                             Error "Could not sync with clean build environment"] style)
                     ("rsync -aHxSpDt --delete '" ++ show (osRoot src) ++ "/' '" ++ show (osRoot dst) ++ "'")
      return dst

-- |To "neuter" an executable is to replace it with a hard link to
-- \/bin\/true in such a way that the operation can be reversed.  This
-- is done in order to make it safe to install files into it when it
-- isn't "live".  If this operation fails it is assumed that the
-- image is damaged, so it is removed.
neuterEnv :: [Style] -> OSImage -> IO OSImage
neuterEnv style os@(OS root _ _ _) =
    do
      msg style ("Neutering OS image (" ++ stripDist (show root) ++ ")...")
      result <- try $ mapM_ (neuterFile os) neuterFiles
      either (\ e -> error $ "Failed to neuter environment " ++ show root ++ ": " ++ show e)
             (\ _ -> return os)
             result

neuterFiles :: [(FilePath, Bool)]
neuterFiles = [("/sbin/start-stop-daemon", True),
	       ("/usr/sbin/invoke-rc.d", True),
	       ("/sbin/init",True),
	       ("/usr/sbin/policy-rc.d", False)]

-- neuter_file from build-env.ml
neuterFile :: OSImage -> (FilePath, Bool) -> IO ()
neuterFile (OS root _ _ _) (file, mustExist) =
    do
      -- putStrLn ("Neutering file " ++ file)
      exists <- doesFileExist (show fullPath)
      if exists then
          neuterExistantFile else
          if mustExist then
              error ("Can't neuter nonexistant file: " ++ show fullPath) else
              return () -- putStrLn "File doesn't exist, nothing to do"

    where
      neuterExistantFile =
          do
            sameFile <- sameInode (show fullPath) (show binTrue)
            if sameFile then
                return () else -- putStrLn "File already neutered"
                neuterUnneuteredFile
      neuterUnneuteredFile =
          do
            hasReal <- doesFileExist (show fullPath ++ ".real")
            if hasReal then
                neuterFileWithRealVersion else
                neuterFileWithoutRealVersion
            createLink (show binTrue) (show fullPath)
      neuterFileWithRealVersion =
          do
            sameCksum <- sameMd5sum (show fullPath) (show fullPath ++ ".real")
            if sameCksum then
                removeFile (show fullPath) else
                error (file ++ " and " ++ file ++ ".real differ (in " ++ show root ++ ")")
                           
      neuterFileWithoutRealVersion = renameFile (show fullPath) (show fullPath ++ ".real")

      fullPath = EnvPath root file
      binTrue = EnvPath root "/bin/true"

-- |Reverse the neuterEnv operation.
restoreEnv :: OSImage -> IO OSImage
restoreEnv os@(OS root _ _ _) =
    do
      hPutStr stderr "De-neutering OS image..."
      result <- try $ mapM_ (restoreFile os) neuterFiles
      either (\ e -> error $ "damaged environment " ++ show root ++ ": " ++ show e ++ "\n  please remove it.")
                 (\ _ -> return os) result

-- check_and_restore from build-env.ml
restoreFile :: OSImage -> (FilePath, Bool) -> IO ()
restoreFile (OS root _ _ _) (file, mustExist) =
    do
      exists <- doesFileExist (show fullPath)
      if exists then
          restoreExistantFile else
          if mustExist then
              error ("Can't restore nonexistant file: " ++ show fullPath) else
              return ()
    where
      restoreExistantFile =
          do
            isTrue <- sameInode (show fullPath) (show binTrue)
            hasReal <- doesFileExist (show fullPath ++ ".real")
            case (isTrue, hasReal) of
              (True, True) ->
                  do
                    removeFile (show fullPath)
                    renameFile (show fullPath ++ ".real") (show fullPath)
              (False, _) -> error "Can't restore file not linked to /bin/true"
              (_, False) -> error "Can't restore file with no .real version"

      fullPath = EnvPath root file
      binTrue = EnvPath root "/bin/true"

-----------------------------------

osRoot :: OSImage -> EnvRoot
osRoot (OS root _ _ _) = root

osDistro :: OSImage -> DistroCache
osDistro (OS _ distro _ _) = distro

osSources :: OSImage -> [DebSource]
osSources (OS _ distro _ repo) =
    DistroCache.sources distro ++
    maybe [] (\ _ -> chrootPool distro) repo

osDist :: OSImage -> String
osDist (OS _ distro _ _) = DistroCache.dist distro

-- FIXME: Compute this with dpkg-architecture and save this in the OSImage
osArch :: OSImage -> String
osArch (OS _ _ arch _) = arch

osLocalRepository :: OSImage -> Maybe Repository
osLocalRepository (OS _ _ _ repo) = repo

-- |Debian Status file (not currently used)
{-
type StatusMap = Map.Map String (Maybe Paragraph)

getStatusFile :: OSImage -> IO StatusMap
getStatusFile (OS root _ _ _ _) =
    parseControlFromFile (show root ++ "/var/lib/dpkg/status") >>=
    return . either (error . show) id >>=
    return . statusMake

statusFind :: String -> StatusMap -> Maybe Paragraph
statusFind name map = Map.findWithDefault Nothing name map

statusMake :: Control -> StatusMap
statusMake (Control control) =
    Map.fromList (map makepair control)
    where
      makepair paragraph =
          case lookupP "Package" paragraph of
            Just (Field (_, name)) -> (stripWS name, Just paragraph)
            _ -> error "A status file entry has no Package field"
-}

-- |Function to get information about source packages using apt-cache showsrc.
getSourceInfo :: [Style] -> OSImage -> [PkgName] -> IO Control
getSourceInfo style root packages =
    runAptCache (addStyles [Start ("getSourceInfo " ++ consperse " " packages ++ " in " ++ osDist root)] style) root "showsrc" packages

-- |Function to get information about binary packages using apt-cache show.
getBinaryInfo :: [Style] -> OSImage -> [PkgName] -> IO Control
getBinaryInfo style root packages =
    runAptCache (addStyles [Start "getBinaryInfo"] style) root "show" packages

-- We get different files in \/var\/lib\/apt\/lists depending on whether we run
-- apt-get update in or out of the changeroot.  Depending on that we need to
-- use a different sources.list to query the cache.
-- FIXME: it would be nice to not use temporary files here, but
-- waitForProcess hangs below.
-- FIXME: Uniquify the list of packages
runAptCache :: [Style] -> OSImage -> String -> [PkgName] -> IO Control
-- |apt-cache will fail if called with no package names
runAptCache _ _ _ [] = return (Control [])
runAptCache style os command packages =
    do
      let opts = aptOpts os
      let cmd = consperse " " ("apt-cache" : opts : command : packages)
      -- FIXME: don't use a temporary file here, but waitForProcess is hanging
{-    (_, outh, _, handle) <- runInteractiveCommand cmd
      control <- parseControlFromHandle ("getPackageInfo " ++ command) outh
      exitcode <- waitForProcess handle
      return $ either (error "Parse error in apt-cache output") id control -}
      let tmp = "/tmp/output"
      systemTask runStyle (cmd ++ " > " ++ tmp)
      control <- parseControlFromFile tmp
      -- If this file is not removed here, its contents may be
      -- replaced by a subsequent call before the parseControlFromFile
      -- is executed.  Seriously!
      removeFile tmp
      return $ either (error "Parse error in apt-cache output") id control
    where
      runStyle = addStyles [Start ("Running apt-cache " ++ command ++ {- " " ++ show packages ++ -}
                                   " in " ++ osDist os)] style

getAvailable :: [Style] -> OSImage -> [String] -> IO [Paragraph]
getAvailable style os names =
    do
      Control available <- getSourceInfo style os names
      return $ sortBy cmp available
    where
      cmp p1 p2 =
          compare v2 v1		-- Flip args to get newest first
          where
            v1 = maybe Nothing (Just . parseDebianVersion) (fieldValue "Version" p1)
            v2 = maybe Nothing (Just . parseDebianVersion) (fieldValue "Version" p2)

aptOpts :: OSImage -> String
aptOpts (OS root _ _ _) =
    (" -o=Dir::State::status=" ++ show root ++ "/var/lib/dpkg/status" ++
     " -o=Dir::State::Lists=" ++ show root ++ "/var/lib/apt/lists" ++
     " -o=Dir::Cache::Archives=" ++ show root ++ "/var/cache/apt/archives" ++
     " -o=Dir::Etc::SourceList=" ++ show root ++ "/etc/apt/sources.list")

-- |Build the dependency relations for the build essential packages.
-- For this to work the build-essential package must be installed in
-- the OSImage.
buildEssential :: OSImage -> Bool -> IO Relations
buildEssential _ True = return []
buildEssential (OS root _ _ _) False =
    do
      essential <-
          readFile (show root ++ "/usr/share/build-essential/essential-packages-list") >>=
          return . lines >>= return . dropWhile (/= "") >>= return . tail >>=
          return . parseRelations . (consperse ", ") >>=
          return . (either (error "parse error in /usr/share/build-essential/essential-packages-list") id)
      let re = mkRegex "^[^ \t]"
      relationText <-
          readFile (show root ++ "/usr/share/build-essential/list") >>=
          return . lines >>=
          return . dropWhile (/= "BEGIN LIST OF PACKAGES") >>= return . tail >>=
          return . takeWhile (/= "END LIST OF PACKAGES") >>=
          return . filter ((/= Nothing) . (matchRegex re))
      -- ePut ("buildEssentialText: " ++ consperse ", " relationText)
      let buildEssential = parseRelations (consperse ", " relationText)
      let buildEssential' = either (\ l -> error ("parse error in /usr/share/build-essential/list:\n" ++ show l)) id buildEssential
      return (essential ++ buildEssential')

buildArch :: OSImage -> IO String
buildArch (OS root _ _ _)  =
    processOutput ("export LOGNAME=root; chroot " ++ show root ++ " dpkg-architecture") >>=
    return . lines . fst >>=
    return . map (mapSnd (drop 1) . break (== '=')) >>=
    return . Map.fromList >>=
    Map.lookup "DEB_BUILD_ARCH"

processOutput :: String -> IO (String, ExitCode)
processOutput command =
    do
      (_, outh, _, handle) <- runInteractiveCommand command
      output <- hGetContents outh
      exitCode <- waitForProcess handle
      return (output, exitCode)

-- |Remove an image.  The removeRecursiveSafely function is used to
-- ensure that any file systems mounted inside the image are unmounted
-- instead of destroyed.
removeEnv :: OSImage -> IO ()
removeEnv (OS root _ _ _) =
    do
      hPutStr stderr "Removing build environment..."
      Unix.removeRecursiveSafely (show root)
      hPutStrLn stderr "done."

{-
      -- mapM_ (createDirectoryIfMissing True . (++ "/dists") . show) copies
      -- syncCopies style repo
-}

syncPool :: [Style] -> OSImage -> IO OSImage
syncPool _ os@(OS _ _ _ Nothing) = return os
syncPool style os@(OS root _ _ (Just repo)) =
    do
      createDirectoryIfMissing True (show root ++ "/work")
      systemTask syncStyle 
                     ("rsync -aHxSpDt --delete '" ++
                      topDir repo ++ "/' '" ++
                      show root ++ "/work/localpool'")
      updateLists style os
      return os
    where
      syncStyle = setStyles [Start ("Syncing local pool from " ++ topDir repo ++ " -> " ++ show root),
                             Error "Failure syncing local pool"] style

updateLists :: [Style] -> OSImage -> IO TimeDiff
updateLists style (OS root _ _ _) =
    systemTask updateStyle
                   ("chroot " ++ show root ++ " bash -c 'unset LANG; apt-get update && apt-get -y dist-upgrade'")
    where
      updateStyle = addStyles [Start ("Updating environment (" ++ stripDist (show root) ++ ")"),
                               Error "Could not update environment", Output Dots] style

sameInode :: FilePath -> FilePath -> IO Bool
sameInode a b =
    do
      aStatus <- getFileStatus a
      bStatus <- getFileStatus b
      return (deviceID aStatus == deviceID bStatus && fileID aStatus == fileID bStatus)

sameMd5sum :: FilePath -> FilePath -> IO Bool
sameMd5sum a b =
    do
      asum <- md5sum a
      bsum <- md5sum b
      return (asum == bsum)

-- |The mighty consperse function
consperse :: [a] -> [[a]] -> [a]
consperse sep items = concat (intersperse sep items)

mapSnd :: (b -> c) -> (a, b) -> (a, c)
mapSnd f (a, b) = (a, f b)

ePut :: String -> IO ()
ePut s = hPutStrLn stderr s