{-# LANGUAGE TypeOperators #-}

-- | Installation to a target disk.
-- 
-- Note that the RevertableProperties in this module are not really
-- revertable; the target disk can't be put back how it was. 
-- The RevertableProperty type is used only to let them  be used
-- in a Versioned Host as shown below.
--
-- Here's an example of a noninteractive installer image using
-- these properties.
--
-- There are two versions of Hosts, the installer and the target system.
-- 
-- > data Variety = Installer | Target
-- > 	deriving (Eq)
-- 
-- The seed of both the installer and the target. They have some properties
-- in common, and some different properties. The `targetInstalled`
-- property knows how to convert the installer it's running on into a
-- target system.
--
-- > seed :: Versioned Variety Host
-- > seed ver = host "debian.local" $ props
-- > 	& osDebian Unstable X86_64
-- > 	& Hostname.sane
-- > 	& Apt.stdSourcesList
-- > 	& Apt.installed ["linux-image-amd64"]
-- > 	& Grub.installed PC
-- > 	& "en_US.UTF-8" `Locale.selectedFor` ["LANG"]
-- > 	& ver ( (== Installer) --> targetInstalled seed Target (userInput ver) parts)
-- > 	& ver ( (== Target)    --> fstabLists (userInput ver) parts)
-- > 	& ver ( (== Installer) --> targetBootable (userInput ver))
-- >   where
-- > 	parts = TargetPartTable MSDOS
-- > 		[ partition EXT4 `mountedAt` "/"
-- > 			`useDiskSpace` RemainingSpace
-- > 		, swapPartition (MegaBytes 1024)
-- > 		]
-- 
-- The installer disk image can then be built from the seed as follows:
-- 
-- > installerBuilt :: RevertableProperty (HasInfo + DebianLike) Linux
-- > installerBuilt = imageBuilt (VirtualBoxPointer "/srv/installer.vmdk")
-- >	(hostChroot (seed `version` installer) (Debootstrapped mempty))
-- >	MSDOS
-- > 	 [ partition EXT4 `mountedAt` "/"
-- >		`setFlag` BootFlag
-- >		`reservedSpacePercentage` 0
-- > 		`addFreeSpace` MegaBytes 256
-- > 	]
--
-- When the installer is booted up, and propellor is run, it installs
-- to the target disk. Since this example is a noninteractive installer,
-- the details of what it installs to are configured before it's built.
-- 
-- > data HardCodedUserInput = HardCodedUserInput (Maybe TargetDiskDevice) (Maybe DiskEraseConfirmed)
-- > 
-- > instance UserInput HardCodedUserInput where 
-- > 	targetDiskDevice (HardCodedUserInput t _) = Just t
-- > 	diskEraseConfirmed (HardCodedUserInput _ c) = Just c
-- > 
-- > userInput :: Version -> HardCodedUserInput
-- > userInput Installer =  HardCodedUserInput Nothing Nothing
-- > userInput Target = HardCodedUserInput (Just (TargetDiskDevice "/dev/sda")) (Just DiskEraseConfirmed)
--
-- For an example of how to use this to make an interactive installer,
-- see <https://git.joeyh.name/index.cgi/secret-project.git/>

module Propellor.Property.Installer.Target (
        -- * Main interface
        TargetPartTable(..),
        targetInstalled,
        fstabLists,
        -- * Additional properties
        mountTarget,
        targetBootable,
        partitionTargetDisk,
        -- * Utility functions
        targetDir,
        probeDisk,
        findDiskDevices,
        -- * Installation progress tracking
        TargetFilled,
        TargetFilledHandle,
        prepTargetFilled,
        checkTargetFilled,
        TargetFilledPercent(..),
        targetFilledPercent,
) where

import Propellor
import Propellor.Property.Installer.Types
import Propellor.Message
import Propellor.Types.Bootloader
import Propellor.Types.PartSpec
import Propellor.Property.Chroot
import Propellor.Property.Versioned
import Propellor.Property.Parted
import Propellor.Property.Mount
import qualified Propellor.Property.Fstab as Fstab
import qualified Propellor.Property.Grub as Grub
import qualified Propellor.Property.Rsync as Rsync

import Text.Read
import Control.Monad
import Control.Monad.IO.Class (liftIO)
import System.Directory
import System.FilePath
import Data.Maybe
import Data.List
import Data.Char
import Data.Ord
import Data.Ratio
import qualified Data.Semigroup as Sem
import System.Process (readProcess)

-- | Partition table for the target disk.
data TargetPartTable = TargetPartTable TableType [PartSpec DiskPart]

-- | Property that installs the target system to the TargetDiskDevice
-- specified in the UserInput. That device will be re-partitioned and
-- formatted and all files erased.
--
-- The installation is done efficiently by rsyncing the installer's files
-- to the target, which forms the basis for a chroot that is provisioned with
-- the specified version of the Host. Thanks to
-- Propellor.Property.Versioned, any unwanted properties of the installer
-- will be automatically reverted in the chroot.
--
-- When there is no TargetDiskDevice or the user has not confirmed the
-- installation, nothing is done except for installing dependencies. 
-- So, this can also be used as a property of the installer
-- image.
targetInstalled
        :: UserInput i
        => Versioned v Host
        -> v
        -> i
        -> TargetPartTable
        -> RevertableProperty (HasInfo + DebianLike) (HasInfo + DebianLike)
targetInstalled vtargethost v userinput (TargetPartTable tabletype partspec) =
        case (targetDiskDevice userinput, diskEraseConfirmed userinput) of
                (Just (TargetDiskDevice targetdev), Just _diskeraseconfirmed) ->
                        go `describe` ("target system installed to " ++ targetdev)
                _ -> tightenTargets installdeps <!> doNothing
  where
        targethost = vtargethost `version` v
        go = RevertableProperty
                (setupRevertableProperty p)
                -- Versioned needs both "sides" of the RevertableProperty
                -- to have the same type, so add empty Info to make the
                -- types line up.
                (undoRevertableProperty p `setInfoProperty` mempty)
          where
                p = partitionTargetDisk userinput tabletype partspec
                        `before` mountTarget userinput partspec
                        `before` provisioned chroot

        chroot = hostChroot targethost RsyncBootstrapper targetDir

        -- Install dependencies that will be needed later when installing
        -- the target.
        installdeps = Rsync.installed

data RsyncBootstrapper = RsyncBootstrapper

instance ChrootBootstrapper RsyncBootstrapper where
        buildchroot RsyncBootstrapper _ target = Right $
                mountaside
                        `before` rsynced
                        `before` umountaside
          where
                -- bind mount the root filesystem to /mnt, which exposes
                -- the contents of all directories that have things mounted
                -- on top of them to rsync.
                mountaside = bindMount "/" "/mnt"
                rsynced = Rsync.rsync
                        [ "--one-file-system"
                        , "-aHAXS"
                        , "--delete"
                        , "/mnt/"
                        , target
                        ]
                umountaside = cmdProperty "umount" ["-l", "/mnt"]
                        `assume` MadeChange

-- | Gets the target mounted.
mountTarget
        :: UserInput i
        => i
        -> [PartSpec DiskPart]
        -> RevertableProperty Linux Linux
mountTarget userinput partspec = setup <!> cleanup
  where
        setup = property "target mounted" $
                case targetDiskDevice userinput of
                        Just (TargetDiskDevice targetdev) -> do
                                liftIO unmountTarget
                                r <- liftIO $ forM tomount $
                                        mountone targetdev
                                if and r
                                        then return MadeChange
                                        else return FailedChange
                        Nothing -> return NoChange
        cleanup = property "target unmounted" $ do
                liftIO unmountTarget
                liftIO $ removeDirectoryRecursive targetDir
                return NoChange

        -- Sort so / comes before /home etc
        tomount = sortOn (fst . fst) $
                map (\((mp, mo, _, _), n) -> ((mp, mo), n)) $
                zip partspec partNums

        mountone targetdev ((mmountpoint, mountopts), num) =
                case mmountpoint of
                        Nothing -> return True
                        Just mountpoint -> do
                                let targetmount = targetDir ++ mountpoint
                                createDirectoryIfMissing True targetmount
                                let dev = diskPartition targetdev num
                                mount "auto" dev targetmount mountopts

-- | Property for use in the target Host to set up its fstab.
-- Should be passed the same TargetPartTable as `targetInstalled`.
fstabLists
        :: UserInput i
        => i
        -> TargetPartTable
        -> RevertableProperty Linux Linux
fstabLists userinput (TargetPartTable _ partspecs) = setup <!> doNothing
  where
        setup = case targetDiskDevice userinput of
                Just (TargetDiskDevice targetdev) ->
                        Fstab.fstabbed mnts (swaps targetdev)
                                `requires` devmounted
                                `before` devumounted
                Nothing -> doNothing

        -- needed for ftabbed UUID probing to work
        devmounted :: Property Linux
        devmounted = tightenTargets $ mounted "devtmpfs" "udev" "/dev" mempty
        devumounted :: Property Linux
        devumounted = tightenTargets $ cmdProperty "umount" ["-l", "/dev"]
                `assume` MadeChange

        partitions = map (\(mp, _, mkpart, _) -> (mp, mkpart mempty)) partspecs
        mnts = mapMaybe fst $
                filter (\(_, p) -> partFs p /= Just LinuxSwap && partFs p /= Nothing) partitions
        swaps targetdev =
                map (Fstab.SwapPartition . diskPartition targetdev . snd) $
                        filter (\((_, p), _) -> partFs p == Just LinuxSwap)
                                (zip partitions partNums)

-- | Make the target bootable using whatever bootloader is installed on it.
targetBootable
        :: UserInput i
        => i
        -> RevertableProperty Linux Linux
targetBootable userinput =
        case (targetDiskDevice userinput, diskEraseConfirmed userinput) of
                (Just (TargetDiskDevice targetdev), Just _diskeraseconfirmed) ->
                        go targetdev <!> doNothing
                _ -> doNothing <!> doNothing
  where
        desc = "bootloader installed on target disk"
        go :: FilePath -> Property Linux
        go targetdev = property' desc $ \w -> do
                bootloaders <- askInfo
                case bootloaders of
                        [GrubInstalled gt] -> ensureProperty w $
                                Grub.bootsMounted targetDir targetdev gt
                        [] -> do
                                warningMessage "no bootloader was installed"
                                return NoChange
                        l -> do
                                warningMessage $ "don't know how to enable bootloader(s) " ++ show l
                                return FailedChange

-- | Partitions the target disk.
partitionTargetDisk
        :: UserInput i
        => i
        -> TableType
        -> [PartSpec DiskPart]
        -> RevertableProperty DebianLike DebianLike
partitionTargetDisk userinput tabletype partspec = go <!> doNothing
  where
        go = check targetNotMounted $ property' "target disk partitioned" $ \w -> do
                case (targetDiskDevice userinput, diskEraseConfirmed userinput) of
                        (Just (TargetDiskDevice targetdev), Just _diskeraseconfirmed) -> do
                                liftIO $ unmountTarget
                                disksize <- liftIO $ getDiskSize targetdev
                                let parttable = calcPartTable disksize tabletype safeAlignment partspec
                                ensureProperty w $
                                        partitioned YesReallyDeleteDiskContents targetdev parttable
                        _ -> error "user input does not allow partitioning disk"

unmountTarget :: IO ()
unmountTarget = mapM_ umountLazy . reverse . sort =<< targetMountPoints

targetMountPoints :: IO [MountPoint]
targetMountPoints = filter isTargetMountPoint <$> mountPoints

isTargetMountPoint :: MountPoint -> Bool
isTargetMountPoint mp =
        mp == targetDir
                || addTrailingPathSeparator targetDir `isPrefixOf` mp

targetNotMounted :: IO Bool
targetNotMounted = not . any (== targetDir) <$> mountPoints

-- | Where the target disk is mounted while it's being installed.
targetDir :: FilePath
targetDir = "/target"

partNums :: [Integer]
partNums = [1..]

-- /dev/sda to /dev/sda1
diskPartition :: FilePath -> Integer -> FilePath
diskPartition dev num = dev ++ show num

-- | This can be used to find a likely disk device to use as the target
-- for an installation.
--
-- This is a bit of a hack; of course the user could be prompted but to
-- avoid prompting, some heuristics...
--   * It should not already be mounted. 
--   * Prefer disks big enough to comfortably hold a Linux installation,
--     so at least 8 gb.
--     (But, if the system only has a smaller disk, it should be used.)
--   * A medium size internal disk is better than a large removable disk,
--     because removable or added drives are often used for data storage
--     on systems with smaller internal disk for the OS.
--     (But, if the internal disk is too small, prefer removable disk;
--     some systems have an unusably small internal disk.)
--   * Prefer the first disk in BIOS order, all other things being equal,
--     because the main OS disk typically comes first. This can be
--     approximated by preferring /dev/sda to /dev/sdb.
probeDisk :: IO TargetDiskDevice
probeDisk = do
        unmountTarget
        mounteddevs <- getMountedDeviceIDs
        let notmounted d = flip notElem (map Just mounteddevs)
                <$> getMinorNumber d
        candidates <- mapM probeCandidate
                =<< filterM notmounted
                =<< findDiskDevices
        case reverse (sort candidates) of
                (Candidate { candidateDevice = Down dev } : _) ->
                        return $ TargetDiskDevice dev
                [] -> error "Unable to find any disk to install to!"

-- | Find disk devices, such as /dev/sda (not partitions)
findDiskDevices :: IO [FilePath]
findDiskDevices = map ("/dev" </>) . filter isdisk
        <$> getDirectoryContents "/dev"
  where
        isdisk ('s':'d':_:[]) = True
        isdisk _ = False

-- | When comparing two Candidates, the better of the two will be larger.
data Candidate = Candidate
        { candidateBigEnoughForOS :: Bool
        , candidateIsFixedDisk :: Bool
        -- use Down so that /dev/sda orders larger than /dev/sdb
        , candidateDevice :: Down FilePath
        } deriving (Eq, Ord)

probeCandidate :: FilePath -> IO Candidate
probeCandidate dev = do
        DiskSize sz <- getDiskSize dev
        isfixeddisk <- not <$> isRemovableDisk dev
        return $ Candidate
                { candidateBigEnoughForOS = sz >= 8 * onegb
                , candidateIsFixedDisk = isfixeddisk
                , candidateDevice = Down dev
                }
  where
        onegb = 1024*1024*1000

newtype MinorNumber = MinorNumber Integer
        deriving (Eq, Show)

getMountedDeviceIDs :: IO [MinorNumber]
getMountedDeviceIDs = mapMaybe parse . lines <$> readProcess "findmnt"
        [ "-rn"
        , "--output"
        , "MAJ:MIN"
        ]
        ""
  where
        parse = fmap MinorNumber . readMaybe
                . dropWhile (not . isDigit) . dropWhile (/= ':')

-- There is not currently a native haskell interface for getting the minor
-- number of a device.
getMinorNumber :: FilePath -> IO (Maybe MinorNumber)
getMinorNumber dev = fmap MinorNumber . readMaybe
        <$> readProcess "stat" [ "--printf", "%T", dev ] ""

-- A removable disk may show up as removable or as hotplug.
isRemovableDisk :: FilePath -> IO Bool
isRemovableDisk dev = do
        isremovable <- checkblk "RM"
        ishotplug <- checkblk "HOTPLUG"
        return (isremovable || ishotplug)
  where
        checkblk field = (== "1\n") <$> readProcess "lsblk"
                [ "-rn"
                , "--nodeps"
                , "--output", field
                , dev
                ]
                ""

getDiskSize :: FilePath -> IO DiskSize
getDiskSize dev = do
        sectors <- fromMaybe 0 . readMaybe
                <$> readProcess "blockdev" ["--getsz", dev] ""
        return (DiskSize (sectors * 512))

getMountsSizes :: IO [(MountPoint, Integer)]
getMountsSizes = mapMaybe (parse . words) . lines <$> readProcess "findmnt" ps ""
  where
        ps = ["-rnb", "-o", "TARGET,USED"]
        parse (mp:szs:[]) = do
                sz <- readMaybe szs
                return (mp, sz)
        parse _ = Nothing

-- | How much of the target disks are used, compared with the size of the
-- installer's root device. Since the main part of an installation
-- is `targetInstalled` rsyncing the latter to the former, this allows
-- roughly estimating the percent done while an install is running,
-- and can be used in some sort of progress display.
data TargetFilled = TargetFilled (Ratio Integer)
        deriving (Show, Eq)

instance Sem.Semigroup TargetFilled where
        TargetFilled n <> TargetFilled m = TargetFilled (n+m)

instance Monoid TargetFilled where
        mempty = TargetFilled (0 % 1)
        mappend = (Sem.<>)

newtype TargetFilledHandle = TargetFilledHandle Integer

-- | Prepare for getting `TargetFilled`.
prepTargetFilled :: IO TargetFilledHandle
prepTargetFilled = go =<< getMountSource "/"
  where
        go (Just dev) = do
                -- Assumes that the installer uses a single partition.
                DiskSize sz <- getDiskSize dev
                return (TargetFilledHandle sz)
        go Nothing = return (TargetFilledHandle 0)

-- | Get the current `TargetFilled` value. This is fast enough to be run
-- multiple times per second without using much CPU.
checkTargetFilled :: TargetFilledHandle -> IO TargetFilled
checkTargetFilled (TargetFilledHandle installsz) = do
        targetsz <- sum . map snd . filter (isTargetMountPoint . fst)
                <$> getMountsSizes
        return (TargetFilled (targetsz % max 1 installsz))

newtype TargetFilledPercent = TargetFilledPercent Int
        deriving (Show, Eq)

targetFilledPercent :: TargetFilled -> TargetFilledPercent
targetFilledPercent (TargetFilled r) = TargetFilledPercent $ floor percent
  where
        percent :: Double
        percent = min 100 (fromRational r * 100)