packages feed

cachix-1.11.1: src/Cachix/Client/Command/Doctor.hs

module Cachix.Client.Command.Doctor (daemonDoctor, doctor) where

import Cachix.API qualified as API
import Cachix.Client.Config qualified as Config
import Cachix.Client.Env (Env (..))
import Cachix.Client.OptionsParser (DaemonOptions (..), DoctorOptions (..))
import Cachix.Client.Retry (retryClientM)
import Cachix.Client.Servant (cachixClient, isErr)
import Cachix.Daemon.Listen (getSocketPath)
import Cachix.Daemon.Protocol qualified as Protocol
import Cachix.Types.BinaryCache (BinaryCache (..))
import Cachix.Types.Permission (Permission (..))
import Control.Exception.Safe qualified as Exception
import Data.Aeson qualified as Aeson
import Data.ByteString qualified as BS
import Data.Text qualified as T
import Network.HTTP.Types (status401, status404)
import Network.Socket qualified as Socket
import Network.Socket.ByteString qualified as Socket.BS
import Network.Socket.ByteString.Lazy qualified as Socket.LBS
import Protolude hiding (toS)
import Protolude.Conv
import Servant.Auth.Client
import Servant.Client.Streaming (ClientError)
import System.Directory (doesFileExist)
import System.Environment (lookupEnv)
import System.Timeout (timeout)

-- | Result of a single check
data CheckResult
  = CheckOK
  | CheckFailed Text
  | CheckWarning Text
  deriving (Show)

-- | Result of checking a cache
data CacheCheckResult = CacheCheckResult
  { cacheResultName :: Text,
    cacheResultUri :: Maybe Text,
    cacheResultPublic :: Maybe Bool,
    cacheResultPermission :: Maybe Permission,
    cacheResultHasSigningKey :: Bool,
    cacheResultConnectivity :: CheckResult,
    cacheResultAuth :: CheckResult
  }

-- | Result of checking daemon
data DaemonCheckResult
  = DaemonNotRunning
  | DaemonRunning FilePath (Maybe Protocol.DaemonDiagnostics)
  | DaemonConnectionFailed Text
  deriving (Show)

doctor :: Env -> DoctorOptions -> IO ()
doctor env opts = do
  -- Check if CACHIX_DAEMON_SOCKET is set and warn user
  envSocketPath <- lookupEnv "CACHIX_DAEMON_SOCKET"
  case envSocketPath of
    Just _ -> do
      putStrLn ("Note: CACHIX_DAEMON_SOCKET is set. Use 'cachix daemon doctor' for daemon-specific diagnostics." :: Text)
      putStrLn ("" :: Text)
    Nothing -> return ()

  putStrLn ("Cachix Doctor" :: Text)
  putStrLn ("=============" :: Text)
  putStrLn ("" :: Text)

  -- Configuration checks
  configResult <- checkConfiguration env
  printConfigResult configResult

  -- Auth check
  authResult <- checkAuth env
  printAuthResult authResult

  -- Cache checks
  cacheResults <- checkCaches env opts
  printCacheResults cacheResults

  -- Store path check (if provided)
  storePathResult <- case doctorStorePath opts of
    Just storePath -> do
      result <- checkStorePath env opts storePath
      printStorePathResult result
      return (Just result)
    Nothing -> return Nothing

  -- Summary
  let allPassed = configPassed configResult && authPassed authResult && cachesPassed cacheResults && storePathOk storePathResult
  putStrLn ("" :: Text)
  if allPassed
    then putStrLn ("All checks passed." :: Text)
    else do
      putStrLn ("Some checks failed." :: Text)
      exitFailure

-- | Daemon-specific doctor command
daemonDoctor :: Env -> DaemonOptions -> IO ()
daemonDoctor _env opts = do
  -- Resolve socket path: CLI option takes precedence over environment variable
  envSocketPath <- fmap toS <$> lookupEnv "CACHIX_DAEMON_SOCKET"
  let socketPath = daemonSocketPath opts <|> envSocketPath

  putStrLn ("Cachix Daemon Doctor" :: Text)
  putStrLn ("====================" :: Text)
  putStrLn ("" :: Text)

  -- Check daemon status
  daemonResult <- checkDaemon socketPath
  printDaemonResult daemonResult

  -- Summary
  let allPassed = case daemonResult of
        DaemonRunning _ (Just diag) -> Protocol.diagAuthOk diag
        DaemonRunning _ Nothing -> True
        DaemonNotRunning -> False
        DaemonConnectionFailed _ -> False
  putStrLn ("" :: Text)
  if allPassed
    then putStrLn ("All checks passed." :: Text)
    else do
      putStrLn ("Some checks failed." :: Text)
      exitFailure

-- Configuration checks
data ConfigCheckResult = ConfigCheckResult
  { configFileResult :: CheckResult,
    authTokenResult :: CheckResult,
    authTokenSource :: Maybe Text
  }

configPassed :: ConfigCheckResult -> Bool
configPassed ConfigCheckResult {..} =
  case (configFileResult, authTokenResult) of
    (CheckFailed _, _) -> False
    (_, CheckFailed _) -> False
    _ -> True -- Warnings are OK for both config file and auth token

checkConfiguration :: Env -> IO ConfigCheckResult
checkConfiguration env = do
  let configPath = Config.configPath (cachixoptions env)

  -- Check config file
  configExists <- doesFileExist configPath
  let configFileRes =
        if configExists
          then CheckOK
          else CheckWarning "Config file not found (using defaults)"

  -- Check auth token
  maybeToken <- Config.getAuthTokenMaybe (config env)
  let (authRes, authSource) = case maybeToken of
        Just (Token t) | not (T.null (toS t)) -> (CheckOK, Just "from config")
        _ -> (CheckWarning "No auth token configured", Nothing)

  return
    ConfigCheckResult
      { configFileResult = configFileRes,
        authTokenResult = authRes,
        authTokenSource = authSource
      }

printConfigResult :: ConfigCheckResult -> IO ()
printConfigResult ConfigCheckResult {..} = do
  putStrLn ("Configuration" :: Text)
  printCheck "Config file" configFileResult Nothing
  printCheck "Auth token" authTokenResult authTokenSource

-- Auth validation
authPassed :: CheckResult -> Bool
authPassed CheckOK = True
authPassed (CheckWarning _) = True
authPassed (CheckFailed _) = False

checkAuth :: Env -> IO CheckResult
checkAuth _env = do
  -- Auth is validated per-cache, so just return OK here
  -- The actual auth validation happens in checkCaches
  return CheckOK

printAuthResult :: CheckResult -> IO ()
printAuthResult _ = return () -- Auth is shown per-cache

-- Cache checks
cachesPassed :: [CacheCheckResult] -> Bool
cachesPassed = all cacheCheckPassed
  where
    cacheCheckPassed CacheCheckResult {..} =
      case (cacheResultConnectivity, cacheResultAuth) of
        (CheckOK, CheckOK) -> True
        (CheckOK, CheckWarning _) -> True
        _ -> False

checkCaches :: Env -> DoctorOptions -> IO [CacheCheckResult]
checkCaches env opts = do
  let configuredCaches = Config.binaryCaches (config env)

  case doctorCacheName opts of
    Just specificCache -> do
      -- Check specific cache
      result <- checkCache env specificCache configuredCaches
      return [result]
    Nothing ->
      if null configuredCaches
        then do
          putStrLn ("" :: Text)
          putStrLn ("No caches configured." :: Text)
          return []
        else do
          -- Check all configured caches
          forM configuredCaches $ \cacheConfig ->
            checkCache env (Config.name cacheConfig) configuredCaches

checkCache :: Env -> Text -> [Config.BinaryCacheConfig] -> IO CacheCheckResult
checkCache env cacheName configuredCaches = do
  maybeToken <- Config.getAuthTokenMaybe (config env)
  let token = fromMaybe (Token "") maybeToken

  -- Check if we have a signing key for this cache
  let hasSigningKey = any (\c -> Config.name c == cacheName && not (T.null (Config.secretKey c))) configuredCaches

  -- Try to get cache info (validates auth and connectivity)
  cacheRes <- retryClientM (clientenv env) $ API.getCache cachixClient token cacheName

  case cacheRes of
    Right bc ->
      return
        CacheCheckResult
          { cacheResultName = cacheName,
            cacheResultUri = Just (uri bc),
            cacheResultPublic = Just (isPublic bc),
            cacheResultPermission = Just (permission bc),
            cacheResultHasSigningKey = hasSigningKey,
            cacheResultConnectivity = CheckOK,
            cacheResultAuth = CheckOK
          }
    Left err ->
      return
        CacheCheckResult
          { cacheResultName = cacheName,
            cacheResultUri = Nothing,
            cacheResultPublic = Nothing,
            cacheResultPermission = Nothing,
            cacheResultHasSigningKey = hasSigningKey,
            cacheResultConnectivity = checkConnectivityError err maybeToken,
            cacheResultAuth = checkAuthError err maybeToken
          }

checkConnectivityError :: ClientError -> Maybe Token -> CheckResult
checkConnectivityError err maybeToken
  | isErr err status404 = CheckFailed "Cache not found"
  | isErr err status401 = case maybeToken of
      Just _ -> CheckOK -- Auth error, not connectivity
      Nothing -> CheckFailed "Authentication required"
  | otherwise = CheckFailed (toS (show err :: [Char]))

checkAuthError :: ClientError -> Maybe Token -> CheckResult
checkAuthError err maybeToken
  | isErr err status401 = case maybeToken of
      Just _ -> CheckFailed "Invalid or expired auth token"
      Nothing -> CheckWarning "Not authenticated (cache may be private)"
  | isErr err status404 = CheckOK -- Not an auth error
  | otherwise = CheckOK

printCacheResults :: [CacheCheckResult] -> IO ()
printCacheResults results = do
  forM_ results $ \result -> do
    putStrLn ("" :: Text)
    putStrLn ("Cache: " <> cacheResultName result)
    case cacheResultUri result of
      Just u -> putStrLn ("  URI: " <> u)
      Nothing -> return ()
    case cacheResultPublic result of
      Just True -> putStrLn ("  Public: yes" :: Text)
      Just False -> putStrLn ("  Public: no" :: Text)
      Nothing -> return ()
    case cacheResultPermission result of
      Just p -> putStrLn ("  Permission: " <> T.toLower (show p))
      Nothing -> return ()
    let signingKeyResult = if cacheResultHasSigningKey result then CheckOK else CheckWarning "not configured"
    printCheck "Signing key" signingKeyResult Nothing
    printCheck "Connectivity" (cacheResultConnectivity result) Nothing
    printCheck "Authentication" (cacheResultAuth result) Nothing

-- Store path checks
data StorePathCheckResult = StorePathCheckResult
  { storePathQuery :: Text,
    storePathHash :: Text,
    storePathCacheName :: Text,
    storePathStatus :: StorePathStatus
  }
  deriving (Show)

data StorePathStatus
  = InCache
  | NotInCache
  | StorePathCheckError Text
  deriving (Show)

storePathOk :: Maybe StorePathCheckResult -> Bool
storePathOk Nothing = True
storePathOk (Just StorePathCheckResult {..}) =
  case storePathStatus of
    InCache -> True
    NotInCache -> True -- Not an error, just informational
    StorePathCheckError _ -> False

-- | Extract the hash from a store path
-- Store paths look like: /nix/store/abc123...-name
-- The hash is the 32-character string after /nix/store/
extractStoreHash :: Text -> Maybe Text
extractStoreHash storePath =
  let path = T.strip storePath
      -- Handle both full paths and just hashes
      normalized =
        if "/nix/store/" `T.isPrefixOf` path
          then T.drop 11 path -- Drop "/nix/store/"
          else path
   in if T.length normalized >= 32
        then Just $ T.take 32 normalized
        else Nothing

checkStorePath :: Env -> DoctorOptions -> Text -> IO StorePathCheckResult
checkStorePath env opts storePath = do
  maybeToken <- Config.getAuthTokenMaybe (config env)
  let token = fromMaybe (Token "") maybeToken
  let configuredCaches = Config.binaryCaches (config env)

  -- Determine which cache to check
  let cacheName = case doctorCacheName opts of
        Just name -> name
        Nothing -> case configuredCaches of
          (first' : _) -> Config.name first'
          [] -> "cachix" -- Default fallback
  case extractStoreHash storePath of
    Nothing ->
      return
        StorePathCheckResult
          { storePathQuery = storePath,
            storePathHash = "",
            storePathCacheName = cacheName,
            storePathStatus = StorePathCheckError "Invalid store path format"
          }
    Just storeHash -> do
      -- Use narinfoBulk to check if the path exists
      result <- retryClientM (clientenv env) $ API.narinfoBulk cachixClient token cacheName [storeHash]
      case result of
        Right missingHashes ->
          let status = if storeHash `elem` missingHashes then NotInCache else InCache
           in return
                StorePathCheckResult
                  { storePathQuery = storePath,
                    storePathHash = storeHash,
                    storePathCacheName = cacheName,
                    storePathStatus = status
                  }
        Left err ->
          return
            StorePathCheckResult
              { storePathQuery = storePath,
                storePathHash = storeHash,
                storePathCacheName = cacheName,
                storePathStatus = StorePathCheckError (toS (show err :: [Char]))
              }

printStorePathResult :: StorePathCheckResult -> IO ()
printStorePathResult StorePathCheckResult {..} = do
  putStrLn ("" :: Text)
  putStrLn ("Store Path" :: Text)
  putStrLn ("  Query: " <> storePathQuery)
  putStrLn ("  Hash: " <> storePathHash)
  putStrLn ("  Cache: " <> storePathCacheName)
  case storePathStatus of
    InCache ->
      printCheck "Status" CheckOK (Just "found in cache")
    NotInCache ->
      printCheck "Status" (CheckWarning "not in cache") Nothing
    StorePathCheckError err ->
      printCheck "Status" (CheckFailed err) Nothing

-- Daemon checks

checkDaemon :: Maybe FilePath -> IO DaemonCheckResult
checkDaemon optionalSocketPath = do
  socketPath <- maybe getSocketPath pure optionalSocketPath
  exists <- doesFileExist socketPath

  if not exists
    then return DaemonNotRunning
    else do
      -- Try to connect and get diagnostics
      result <- Exception.try $ queryDaemonDiagnostics socketPath
      case result of
        Right (Just diag) -> return $ DaemonRunning socketPath (Just diag)
        Right Nothing -> return $ DaemonConnectionFailed "No response from daemon"
        Left (e :: SomeException) -> return $ DaemonConnectionFailed (toS $ displayException e)

queryDaemonDiagnostics :: FilePath -> IO (Maybe Protocol.DaemonDiagnostics)
queryDaemonDiagnostics socketPath =
  Exception.bracket
    (Socket.socket Socket.AF_UNIX Socket.Stream Socket.defaultProtocol)
    Socket.close
    ( \sock -> do
        Socket.connect sock (Socket.SockAddrUnix socketPath)
        -- Send diagnostics request
        Socket.LBS.sendAll sock $ Protocol.newMessage Protocol.ClientDiagnosticsRequest
        -- Wait for response with timeout (10 seconds - auth check may take time)
        result <- timeout 10000000 $ receiveResponse sock
        case result of
          Just (Just (Protocol.DaemonDiagnosticsResult diag)) -> return (Just diag)
          _ -> return Nothing
    )

receiveResponse :: Socket.Socket -> IO (Maybe Protocol.DaemonMessage)
receiveResponse sock = do
  bs <- Socket.BS.recv sock 4096
  if BS.null bs
    then return Nothing
    else do
      let (msgs, _) = Protocol.splitMessages bs
      case msgs of
        (msg : _) -> return $ decodeMsg msg
        [] -> return Nothing
  where
    decodeMsg msg = case Aeson.eitherDecodeStrict msg of
      Right m -> Just m
      Left _ -> Nothing

printDaemonResult :: DaemonCheckResult -> IO ()
printDaemonResult result = do
  putStrLn ("" :: Text)
  putStrLn ("Daemon" :: Text)
  case result of
    DaemonNotRunning ->
      printCheck "Status" (CheckWarning "not running") Nothing
    DaemonRunning socketPath _ -> do
      printCheck "Status" CheckOK (Just "running")
      putStrLn ("  Socket: " <> (toS socketPath :: Text))
    DaemonConnectionFailed reason ->
      printCheck "Status" (CheckFailed reason) Nothing

-- Helper to print a check result
printCheck :: Text -> CheckResult -> Maybe Text -> IO ()
printCheck name result extra = do
  let (symbol, status) = case result of
        CheckOK -> ("✓", "")
        CheckWarning msg -> ("!", msg)
        CheckFailed msg -> ("✗", msg)
      extraText = case (extra, status) of
        (Just e, "") -> e
        (Just e, s) -> s <> " (" <> e <> ")"
        (Nothing, s) -> s
      statusText = if T.null extraText then "" else " " <> extraText
  putStrLn $ "  " <> symbol <> " " <> name <> statusText