packages feed

nova-cache-0.12.0.0: exe/ReclaimNars.hs

-- | The @reclaim-nars@ subcommand: list, and with @--delete@ remove,
-- the NARs in a store directory that no narinfo references.
--
-- A host tool run against the store directory rather than a route,
-- since nothing over HTTP should be able to delete.  The decision is
-- "NovaCache.Reclaim"'s; this module parses arguments, prints, and
-- deletes.
module ReclaimNars (reclaimCommand, reclaimMain) where

import Control.Exception (IOException, displayException, try)
import Control.Monad (when)
import Data.Foldable (for_, traverse_)
import Data.Time.Clock (NominalDiffTime)
import NovaCache.Reclaim (ReclaimBlocked (..), ReclaimPlan (..), Unresolved (..), defaultMinimumAge, planReclaim, scanStore)
import NovaCache.Store (FileStore, NarFile (..), NarRemoveResult (..), fileStoreAt, removeNar)
import System.Exit (exitFailure)
import System.IO (BufferMode (LineBuffering), hPutStrLn, hSetBuffering, stderr, stdout)
import System.IO.Error (ioeGetErrorString)
import Text.Read (readMaybe)

-- ---------------------------------------------------------------------------
-- Arguments
-- ---------------------------------------------------------------------------

-- | The subcommand's name, the first argument to the executable.
reclaimCommand :: String
reclaimCommand = "reclaim-nars"

storeFlag, minAgeFlag, deleteFlag :: String
storeFlag = "--store"
minAgeFlag = "--min-age-hours"
deleteFlag = "--delete"

usage :: String
usage =
  "usage: nova-cache-server "
    ++ reclaimCommand
    ++ " ["
    ++ storeFlag
    ++ " DIR] ["
    ++ minAgeFlag
    ++ " N] ["
    ++ deleteFlag
    ++ "]"

secondsPerHour :: NominalDiffTime
secondsPerHour = 3600

-- | Whether a run removes what it finds.  Listing is the default.
data Mode = ListOnly | Delete

data Options = Options
  { optStoreRoot :: !FilePath,
    optMinimumAge :: !NominalDiffTime,
    optMode :: !Mode
  }

-- | Parse the subcommand's arguments over the store root the
-- environment chose.  Unlike the server's flags, an unknown or
-- malformed argument is an error: a typo here must not run with a
-- default the operator did not choose.
parseOptions :: FilePath -> [String] -> Either String Options
parseOptions storeRoot =
  go Options {optStoreRoot = storeRoot, optMinimumAge = defaultMinimumAge, optMode = ListOnly}
  where
    go opts [] = Right opts
    go opts (flag : value : rest)
      | flag == storeFlag = go opts {optStoreRoot = value} rest
      | flag == minAgeFlag = case readMaybe value of
          Just hours
            | hours >= (0 :: Integer) ->
                go opts {optMinimumAge = fromInteger hours * secondsPerHour} rest
          _ -> Left (minAgeFlag ++ " takes a whole number of hours, not " ++ show value)
    go opts (flag : rest)
      | flag == deleteFlag = go opts {optMode = Delete} rest
      | flag == storeFlag || flag == minAgeFlag = Left (flag ++ " needs a value")
      | otherwise = Left ("unknown argument: " ++ flag)

-- ---------------------------------------------------------------------------
-- Command
-- ---------------------------------------------------------------------------

-- | Run the subcommand.  Exits nonzero when the arguments are wrong,
-- the store cannot be listed, the run is blocked, or a deletion fails.
reclaimMain :: FilePath -> [String] -> IO ()
reclaimMain storeRoot args = case parseOptions storeRoot args of
  Left err -> do
    hPutStrLn stderr err
    hPutStrLn stderr usage
    exitFailure
  Right opts -> do
    -- Piped stdout is block buffered by default, which would print the
    -- report out of order with the FAILED and FATAL lines on stderr.
    hSetBuffering stdout LineBuffering
    let root = optStoreRoot opts
        store = fileStoreAt root
    putStrLn ("store root: " ++ root)
    putStrLn ("minimum age: " ++ counted (wholeHours (optMinimumAge opts)) "hour")
    scanned <- try (scanStore store)
    case scanned of
      Left err ->
        fatal ("cannot scan the store: " ++ displayException (err :: IOException))
      Right scan -> case planReclaim (optMinimumAge opts) scan of
        Left blocked -> refuse blocked
        Right plan -> case optMode opts of
          ListOnly -> listPlan plan
          Delete -> deletePlan store plan

-- | List what a deleting run would remove.
listPlan :: ReclaimPlan -> IO ()
listPlan plan = do
  traverse_ (putStrLn . ("unreferenced: " ++) . describe) (rpEligible plan)
  putStrLn
    ( "eligible: "
        ++ counted (length (rpEligible plan)) "NAR"
        ++ ", "
        ++ counted (sum (map nfSize (rpEligible plan))) "byte"
        ++ " (pass "
        ++ deleteFlag
        ++ " to remove)"
    )
  reportTooYoung plan

-- | Remove every eligible NAR, reporting each, and exit nonzero if any
-- removal failed.
deletePlan :: FileStore -> ReclaimPlan -> IO ()
deletePlan store plan = do
  outcomes <- traverse removeOne (rpEligible plan)
  let removed = [nar | (nar, NarRemoved) <- outcomes]
      failures = length outcomes - length removed
  putStrLn
    ( "reclaimed: "
        ++ counted (length removed) "NAR"
        ++ ", "
        ++ counted (sum (map nfSize removed)) "byte"
    )
  reportTooYoung plan
  when (failures > 0) $
    fatal ("failed to delete " ++ counted failures "NAR")
  where
    removeOne nar = do
      outcome <- removeNar store (nfName nar)
      case outcome of
        NarRemoved -> putStrLn ("deleted: " ++ describe nar)
        NarRemoveFailed err -> failed nar (ioeGetErrorString err)
        NarRemoveBadPath -> failed nar "not a NAR filename"
      pure (nar, outcome)
    failed nar reason = hPutStrLn stderr ("FAILED: " ++ nfPath nar ++ ": " ++ reason)

-- | Report what blocks the run and exit nonzero.
refuse :: ReclaimBlocked -> IO ()
refuse (UnresolvedNarInfos unresolved) = do
  for_ unresolved $ \u ->
    hPutStrLn stderr ("UNRESOLVED: " ++ unresolvedPath u ++ ": " ++ unresolvedReason u)
  fatal
    ( counted (length unresolved) "unresolved narinfo"
        ++ ": nothing was listed or deleted, since the NAR an unresolved narinfo names"
        ++ " would look unreferenced.  Fix or remove each one and run again."
    )
refuse NoNarInfos =
  fatal
    ( "the store holds NARs and no narinfo: nothing was listed or deleted, since a store"
        ++ " that lost its narinfos looks the same as one whose every NAR is unreferenced."
        ++ "  Restore the narinfos, or remove the NARs by hand if none is referenced."
    )

reportTooYoung :: ReclaimPlan -> IO ()
reportTooYoung plan =
  putStrLn ("kept, younger than the minimum age: " ++ counted (length (rpTooYoung plan)) "NAR")

describe :: NarFile -> String
describe nar = nfPath nar ++ " (" ++ counted (nfSize nar) "byte" ++ ")"

fatal :: String -> IO ()
fatal msg = do
  hPutStrLn stderr ("FATAL: " ++ msg)
  exitFailure

wholeHours :: NominalDiffTime -> Integer
wholeHours age = round (age / secondsPerHour)

-- | A count with its noun: @1 NAR@, @2 NARs@.
counted :: (Show a, Eq a, Num a) => a -> String -> String
counted 1 noun = "1 " ++ noun
counted n noun = show n ++ " " ++ noun ++ "s"