packages feed

salmon-ops-0.1.0.0: src/Salmon/Builtin/Nodes/Debian/AptRepository.hs

{-# LANGUAGE OverloadedStrings #-}

{- | An external apt repository as a node: a deb822 @.sources@ file naming a
pre-provisioned signing key, an optional @preferences.d@ pin, and the
@apt-get update@ that makes the index know about it.

Nothing here decides /whether/ a recipe wants an external repository — that
is a risk somebody has to choose to take. A recipe that needs packages from
one takes a @'Salmon.Builtin.Extension.Track'' 'AptRepository'@ argument and
its author (or its seed) passes 'aptRepositoryTrack' to take the risk, or
'Salmon.Builtin.Extension.ignoreTrack' to require that the package is
installable already. See 'pgdg' for the first user.

= What the node refuses

* __A key whose fingerprint is not the declared one.__ The key is a file
  somebody provisioned (this module does not care how it got there: rsync,
  a secret store, the repository's own download page), and the fingerprint
  is what the declaration was written against. It is read with
  @gpg --show-keys --with-colons@ before the key is installed, and a
  mismatch throws 'KeyFingerprintMismatch'. Only /primary/ keys count: a
  subkey's fingerprint is not a statement about who published the file.
  The fingerprint is __declared, never read from a file next to the key__,
  because a pin that travels with the thing it pins pins nothing.
* __Shadowing the distribution.__ A repository that carries packages the
  distribution also has (PGDG ships @postgresql-<major>@) wins by version
  under apt's default priorities. So the default 'Pinning' is
  'OnlyPackages': the repository is pinned to priority 1 for everything and
  500 for the named patterns, and the whole suite ('WholeSuite') is an
  explicit choice.

= down

Removes the sources file, the preference file and the key. Packages that were
installed from the repository are left alone (removing them is the business
of whichever node installed them), and no @apt-get update@ is run: the stale
index entries go on the next update anybody runs.

= Requirements on the machine

@gpg@ (the @gpg@ package on Debian), and root for anything outside a test
root. 'aptRepository' does not install @gpg@ itself: tearing the repository
down should not uninstall a tool something else may be using.
-}
module Salmon.Builtin.Nodes.Debian.AptRepository (
    AptRepository (..),
    Suite (..),
    Pinning (..),
    KeyFingerprintMismatch (..),
    aptRepository,
    aptRepositoryTrack,
    viaRepository,
    pgdg,

    -- * Pieces, exposed for tests
    renderSources,
    renderPreferences,
    resolveSuite,
    parseOsReleaseCodename,
    primaryFingerprints,
    normalizeFingerprint,
    repositoryHost,
    listsPrefix,
    keyDestination,
    sourcesPath,
    preferencesPath,
) where

import Control.Exception (Exception, throwIO)
import Control.Monad (unless, when)
import qualified Data.ByteString as ByteString
import Data.Foldable (toList)
import qualified Data.List.NonEmpty as NEList
import Data.Text (Text)
import qualified Data.Text as Text
import qualified Data.Text.Encoding as Text
import GHC.IO.Exception (ExitCode (..))
import System.Directory (
    createDirectoryIfMissing,
    doesDirectoryExist,
    doesFileExist,
    getModificationTime,
    listDirectory,
 )
import System.FilePath (takeDirectory, takeExtension, (</>))
import System.Process.ByteString (readCreateProcessWithExitCode)
import System.Process.ListLike (proc)

import Salmon.Actions.UpDown (CheckResult (..))
import Salmon.Builtin.Extension
import Salmon.Builtin.Nodes.Filesystem (FileContents (..), checkFileContents, removeFileIfPresent)
import qualified Salmon.Builtin.Nodes.Binary as Binary
import Salmon.Op.Ref
import Salmon.Op.Track (Track (..))

-- | How the repository's suite is spelled.
data Suite
    = -- | The machine's @VERSION_CODENAME@ (from @/etc/os-release@, read when
      -- the node runs, not when the directive is written) followed by this
      -- suffix: @CodenameSuffixed "-pgdg"@ is @bookworm-pgdg@ on bookworm.
      CodenameSuffixed Text
    | -- | A suite that does not depend on the machine.
      FixedSuite Text
    deriving (Eq, Show)

-- | What the repository is allowed to supply.
data Pinning
    = -- | Only packages matching these patterns (apt's @Package:@ globs,
      -- e.g. @postgresql-*-pgvector@) are taken from the repository; every
      -- other package it carries stays at priority 1, i.e. is installed
      -- from it only when nothing else has it.
      OnlyPackages (NEList.NonEmpty Text)
    | -- | No preference file: the whole suite competes at apt's default
      -- priority, and a newer version there wins over the distribution's.
      WholeSuite
    deriving (Eq, Show)

data AptRepository = AptRepository
    { repoName :: Text
    -- ^ Stem of every file the node writes (@\<name\>.sources@, the key, the
    -- preference file); also the node's identity.
    , repoUris :: Text
    , repoSuite :: Suite
    , repoComponents :: [Text]
    , repoKeyFile :: FilePath
    -- ^ The pre-provisioned key (armored @.asc@ or binary @.gpg@; the
    -- extension is kept on the installed copy, since apt tells them apart
    -- by it).
    , repoKeyFingerprint :: Text
    -- ^ Full primary-key fingerprint, spaces and case ignored.
    , repoPin :: Pinning
    , repoAptDir :: FilePath
    -- ^ @/etc/apt@; a field so that a test can aim the node at a temporary
    -- directory.
    , repoListsDir :: FilePath
    -- ^ @/var/lib/apt/lists@, where the refreshed index shows up.
    }
    deriving (Eq, Show)

-- | The PostgreSQL project's repository (@apt.postgresql.org@), pinned to
-- @postgresql-*-pgvector@ only. Override 'repoPin' for other packages. The
-- fingerprint is the caller's to declare; see the module header for why it
-- is not baked in.
pgdg :: FilePath -> Text -> AptRepository
pgdg keyFile fingerprint =
    AptRepository
        { repoName = "pgdg"
        , repoUris = "https://apt.postgresql.org/pub/repos/apt"
        , repoSuite = CodenameSuffixed "-pgdg"
        , repoComponents = ["main"]
        , repoKeyFile = keyFile
        , repoKeyFingerprint = fingerprint
        , repoPin = OnlyPackages ("postgresql-*-pgvector" NEList.:| [])
        , repoAptDir = "/etc/apt"
        , repoListsDir = "/var/lib/apt/lists"
        }

-- | The value to pass where a recipe takes @Track' AptRepository@ and its
-- author decided to take the risk of the external repository.
aptRepositoryTrack :: Track' AptRepository
aptRepositoryTrack = Track aptRepository

-- | For a builtin that takes its package source as a @'Track'' ()@: provision
-- this repository first. Its counterpart for \"the package is already
-- installable\" is 'Salmon.Builtin.Extension.ignoreTrack'.
viaRepository :: AptRepository -> Track' ()
viaRepository = Track . const . aptRepository

-- | Thrown before a key is installed when none of its primary-key
-- fingerprints is the declared one.
data KeyFingerprintMismatch = KeyFingerprintMismatch
    { mismatchFile :: FilePath
    , mismatchDeclared :: Text
    , mismatchFound :: [Text]
    }

instance Show KeyFingerprintMismatch where
    show e =
        "key fingerprint mismatch for "
            <> e.mismatchFile
            <> ": declared "
            <> Text.unpack e.mismatchDeclared
            <> ", file has "
            <> (if null e.mismatchFound then "no primary key" else Text.unpack (Text.intercalate ", " e.mismatchFound))

instance Exception KeyFingerprintMismatch

-- | Key, then sources file (and preference file), then the index refresh.
-- The node returned is the refresh: depending on it is depending on the
-- repository being usable.
aptRepository :: AptRepository -> Op
aptRepository repo =
    op "apt-repository" (deps [sourcesNode, preferencesNode]) $ \actions ->
        actions
            { help = "refreshes the apt index for " <> repo.repoName
            , notes =
                [ "repository: " <> repo.repoUris
                , "key fingerprint pinned: " <> normalizeFingerprint repo.repoKeyFingerprint
                , case repo.repoPin of
                    OnlyPackages ps -> "only packages: " <> Text.unwords (toList ps)
                    WholeSuite -> "whole suite (no pin)"
                ]
            , ref = mkRef "apt-repository-index" repo.repoName
            , check = checkIndexFresh repo
            , up = runAptUpdate
            , down = pure ()
            }
  where
    keyNode = keyOp repo
    sourcesNode = sourcesOp repo keyNode
    preferencesNode = preferencesOp repo

keyOp :: AptRepository -> Op
keyOp repo =
    op "apt-repository-key" nodeps $ \actions ->
        actions
            { help = "installs the signing key for " <> repo.repoName
            , notes = ["pinned fingerprint: " <> normalizeFingerprint repo.repoKeyFingerprint]
            , ref = mkRef "apt-repository-key" dest
            , check = checkKey
            , up = do
                verifyKeyFingerprint repo.repoKeyFile repo.repoKeyFingerprint
                key <- ByteString.readFile repo.repoKeyFile
                createDirectoryIfMissing True (takeDirectory dest)
                ByteString.writeFile dest key
            , down = removeFileIfPresent dest
            }
  where
    dest = keyDestination repo

    checkKey :: IO CheckResult
    checkKey = do
        installed <- doesFileExist dest
        if not installed
            then pure (Failure ("missing: " <> Text.pack dest))
            else do
                a <- ByteString.readFile repo.repoKeyFile
                b <- ByteString.readFile dest
                pure $
                    if a == b
                        then Success
                        else Failure ("contents differ: " <> Text.pack dest)

sourcesOp :: AptRepository -> Op -> Op
sourcesOp repo keyNode =
    op "apt-repository-sources" (deps [keyNode]) $ \actions ->
        actions
            { help = "writes " <> Text.pack path
            , notes = ["suite is derived from /etc/os-release when this runs"]
            , ref = mkRef "apt-repository-sources" path
            , check = checkFileContents fc
            , up = do
                bytes <- rendered
                createDirectoryIfMissing True (takeDirectory path)
                ByteString.writeFile path bytes
            , down = removeFileIfPresent path
            }
  where
    path = sourcesPath repo
    rendered :: IO ByteString.ByteString
    rendered = do
        suite <- resolveSuite repo.repoSuite <$> readCodename
        pure (Text.encodeUtf8 (renderSources repo suite))
    fc :: FileContents (IO ByteString.ByteString)
    fc = FileContents path rendered

preferencesOp :: AptRepository -> Op
preferencesOp repo = case repo.repoPin of
    WholeSuite -> realNoop
    OnlyPackages pats ->
        op "apt-repository-preferences" nodeps $ \actions ->
            actions
                { help = "writes " <> Text.pack path
                , ref = mkRef "apt-repository-preferences" path
                , check = checkFileContents (FileContents path body)
                , up = do
                    createDirectoryIfMissing True (takeDirectory path)
                    ByteString.writeFile path body
                , down = removeFileIfPresent path
                }
      where
        body = Text.encodeUtf8 (renderPreferences repo pats)
  where
    path = preferencesPath repo

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

sourcesPath, preferencesPath, keyDestination :: AptRepository -> FilePath
sourcesPath repo = repo.repoAptDir </> "sources.list.d" </> Text.unpack repo.repoName <> ".sources"
preferencesPath repo = repo.repoAptDir </> "preferences.d" </> Text.unpack repo.repoName <> ".pref"
keyDestination repo =
    repo.repoAptDir </> "keyrings" </> Text.unpack repo.repoName <> ext
  where
    ext = case takeExtension repo.repoKeyFile of
        "" -> ".gpg"
        e -> e

-- | The deb822 stanza, for a suite already resolved.
renderSources :: AptRepository -> Text -> Text
renderSources repo suite =
    Text.unlines
        [ "Types: deb"
        , "URIs: " <> repo.repoUris
        , "Suites: " <> suite
        , "Components: " <> Text.unwords repo.repoComponents
        , "Signed-By: " <> Text.pack (keyDestination repo)
        ]

-- | Priority 1 for the whole origin, then 500 for each named pattern.
renderPreferences :: AptRepository -> NEList.NonEmpty Text -> Text
renderPreferences repo pats =
    Text.intercalate "\n" (stanza "*" 1 : [stanza p 500 | p <- toList pats])
  where
    stanza pat prio =
        Text.unlines
            [ "Package: " <> pat
            , "Pin: origin " <> repositoryHost repo.repoUris
            , "Pin-Priority: " <> Text.pack (show (prio :: Int))
            ]

resolveSuite :: Suite -> Text -> Text
resolveSuite (FixedSuite s) _ = s
resolveSuite (CodenameSuffixed suffix) codename = codename <> suffix

-- | @VERSION_CODENAME@ from an @os-release@ file's text, unquoted.
parseOsReleaseCodename :: Text -> Maybe Text
parseOsReleaseCodename contents =
    case [Text.strip v | l <- Text.lines contents, Just v <- [Text.stripPrefix "VERSION_CODENAME=" (Text.strip l)]] of
        (v : _) | not (Text.null (unquote v)) -> Just (unquote v)
        _ -> Nothing
  where
    unquote = Text.dropAround (`elem` ['"', '\''])

readCodename :: IO Text
readCodename = do
    contents <- Text.decodeUtf8 <$> ByteString.readFile "/etc/os-release"
    maybe (ioError (userError "apt repository: no VERSION_CODENAME in /etc/os-release")) pure (parseOsReleaseCodename contents)

-- | The host part of a URI: what a @Pin: origin@ line matches.
repositoryHost :: Text -> Text
repositoryHost uri = Text.takeWhile (/= '/') (afterScheme uri)

afterScheme :: Text -> Text
afterScheme uri = case Text.breakOn "://" uri of
    (_, rest) | not (Text.null rest) -> Text.drop 3 rest
    _ -> uri

-- | The prefix apt gives the index files it downloads for a URI under
-- @lists/@: the URI without its scheme, slashes turned to underscores.
listsPrefix :: Text -> Text
listsPrefix = Text.map (\c -> if c == '/' then '_' else c) . Text.dropWhileEnd (== '/') . afterScheme

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

-- | Upper-case, no spaces.
normalizeFingerprint :: Text -> Text
normalizeFingerprint = Text.toUpper . Text.filter (\c -> c /= ' ' && c /= '\t')

{- | The fingerprints of the /primary/ keys in @gpg --with-colons@ output:
the @fpr@ record that directly follows a @pub@ record. (A subkey's @fpr@
follows a @sub@; a user id's records follow the @fpr@.)
-}
primaryFingerprints :: Text -> [Text]
primaryFingerprints out = go (Text.splitOn ":" <$> Text.lines out)
  where
    go (("pub" : _) : rest) = case dropWhile (not . isKind ["fpr", "pub"]) rest of
        (("fpr" : fields) : rest') -> fprField fields : go rest'
        rest' -> go rest'
    go (_ : rest) = go rest
    go [] = []
    isKind ks (k : _) = k `elem` ks
    isKind _ [] = False
    -- fpr:::::::::<FINGERPRINT>:
    fprField fields = normalizeFingerprint (case drop 8 fields of (f : _) -> f; [] -> "")

verifyKeyFingerprint :: FilePath -> Text -> IO ()
verifyKeyFingerprint file wanted = do
    (code, out, err) <- readCreateProcessWithExitCode (proc "gpg" ["--show-keys", "--with-colons", "--with-fingerprint", file]) ""
    when (code /= ExitSuccess) $
        ioError (userError ("apt repository: gpg --show-keys failed on " <> file <> ": " <> Text.unpack (Text.decodeUtf8 err)))
    let fps = primaryFingerprints (Text.decodeUtf8 out)
    unless (normalizeFingerprint wanted `elem` fps) $
        throwIO (KeyFingerprintMismatch file (normalizeFingerprint wanted) fps)

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

{- | Has the index been refreshed since the repository was last (re)declared?
The newest of the sources and preference files is the moment the
declaration last changed; an index file for this URI at least that new means
an @apt-get update@ has seen it. Nothing else can say so: apt keeps no record
of which sources it has fetched other than these files.
-}
checkIndexFresh :: AptRepository -> IO CheckResult
checkIndexFresh repo = do
    haveLists <- doesDirectoryExist repo.repoListsDir
    if not haveLists
        then pure (Failure ("no index directory: " <> Text.pack repo.repoListsDir))
        else do
            sourcesTime <- getModificationTime (sourcesPath repo)
            prefTimes <- traverse getModificationTime =<< filterExisting [preferencesPath repo]
            let declaredAt = maximum (sourcesTime : prefTimes)
            names <- listDirectory repo.repoListsDir
            let prefix = Text.unpack (listsPrefix repo.repoUris)
                ours = [repo.repoListsDir </> n | n <- names, take (length prefix) n == prefix]
            times <- traverse getModificationTime ours
            pure $
                if any (>= declaredAt) times
                    then Success
                    else Failure ("index older than the declaration of " <> repo.repoName)
  where
    filterExisting = fmap concat . traverse (\p -> (\e -> [p | e]) <$> doesFileExist p)

runAptUpdate :: IO ()
runAptUpdate = do
    (code, _out, err) <- readCreateProcessWithExitCode (proc "apt-get" ["update", "-q"]) ""
    case code of
        ExitSuccess -> pure ()
        ExitFailure n -> throwIO (Binary.CommandFailedSimple ("apt-get update: " <> take 500 (Text.unpack (Text.decodeUtf8 err))) n)