packages feed

salmon-ops-0.1.0.0: src/Salmon/Actions/Follow/Registry.hs

{-# LANGUAGE OverloadedStrings #-}

{- | Which registry @--follow@ names, from the shape of its argument
(milestone 6 of @specs/pull-mode.md@). Every backend is a
'Salmon.Actions.Follow.Registry' value owning the template that turns a
label into an address; the fetcher and the scheduler see none of this.

> --follow /srv/reg                          a directory: /srv/reg/<label>.json
> --follow git+https://host/repo#main:hosts  a git branch: hosts/<label>.json at origin/main
> --follow https://host/seed/latest/{label}  HTTP: GET that URL, or <base>/<label>.json without {label}
> --follow dns:fleet.example                 a TXT index at <label>.fleet.example, fetched over HTTP
> --follow s3://bucket/prefix                a bucket, over its plain HTTPS object URLs
> --follow gs://bucket/prefix                likewise, Google's

The bucket backends are the HTTP one under a URL template:
@https://\<bucket\>.s3.amazonaws.com/\<prefix\>/\<label\>.json@,
@https://storage.googleapis.com/\<bucket\>/\<prefix\>/\<label\>.json@, or
path-style under an S3-compatible endpoint given with
@--follow-bucket-endpoint@. That covers a public bucket, or one fronted by
something that signs — no SDK, and __no authenticated access__: a private
bucket answers @403@, which is a failed round and says so.
-}
module Salmon.Actions.Follow.Registry (
    Address (..),
    parseAddress,
    Bucket (..),
    Store (..),
    bucketTemplate,
    Options (..),
    defaultOptions,
    open,
    defaultWorkdir,
) where

import Data.Text (Text)
import qualified Data.Text as Text
import System.Directory (getTemporaryDirectory)
import System.FilePath ((</>))

import Salmon.Actions.Follow (Registry (..), digestOf, directoryRegistry, unDigest)
import qualified Salmon.Actions.Follow.Registry.Dns as Dns
import qualified Salmon.Actions.Follow.Registry.Git as Git
import qualified Salmon.Actions.Follow.Registry.Http as Http
import qualified Data.ByteString.Lazy.Char8 as LChar8

-- | What @--follow@ can name.
data Address
    = Directory FilePath
    | Git Git.Source
    | Http Text
    | Dns Text
    | InBucket Bucket
    deriving (Show, Eq)

data Store = S3 | Gcs
    deriving (Show, Eq)

-- | @s3://bucket/prefix@ or @gs://bucket/prefix@; the prefix may be empty.
data Bucket = Bucket
    { bucketStore :: Store
    , bucketName :: Text
    , bucketPrefix :: Text
    }
    deriving (Show, Eq)

-- | By shape; anything with no recognised scheme is a directory path.
parseAddress :: Text -> Either Text Address
parseAddress t
    | Just rest <- Text.stripPrefix "git+" t = Git <$> Git.parseSource rest
    | Text.isPrefixOf "http://" t || Text.isPrefixOf "https://" t = Right (Http t)
    | Just zone <- Text.stripPrefix "dns:" t =
        if Text.null zone then Left "dns: needs a zone" else Right (Dns zone)
    | Just rest <- Text.stripPrefix "s3://" t = InBucket <$> bucket S3 rest
    | Just rest <- Text.stripPrefix "gs://" t = InBucket <$> bucket Gcs rest
    | Text.null t = Left "--follow needs a registry"
    | otherwise = Right (Directory (Text.unpack t))
  where
    bucket store rest =
        let (name, prefix) = Text.breakOn "/" rest
         in if Text.null name
                then Left ("a bucket address needs a bucket name: " <> t)
                else Right (Bucket store name (Text.dropWhileEnd (== '/') (Text.drop 1 prefix)))

{- | The bucket's HTTPS base URL, which "Salmon.Actions.Follow.Registry.Http"
then appends @/\<label\>.json@ to. Virtual-hosted for S3 proper, path-style
under an endpoint (what MinIO and friends expect) and for GCS. -}
bucketTemplate :: Maybe Text -> Bucket -> Text
bucketTemplate endpoint b =
    Text.dropWhileEnd (== '/') base <> (if Text.null b.bucketPrefix then "" else "/" <> b.bucketPrefix)
  where
    base = case (endpoint, b.bucketStore) of
        (Just e, _) -> Text.dropWhileEnd (== '/') e <> "/" <> b.bucketName
        (Nothing, S3) -> "https://" <> b.bucketName <> ".s3.amazonaws.com"
        (Nothing, Gcs) -> "https://storage.googleapis.com/" <> b.bucketName

-- | What the backends need beyond their address.
data Options = Options
    { optHttp :: Http.Options
    , optWorkdir :: Maybe FilePath
    -- ^ the git checkout; 'defaultWorkdir' when 'Nothing'
    , optCacheDir :: Maybe FilePath
    -- ^ @--follow-cache@, which the default checkout lives under
    , optBucketEndpoint :: Maybe Text
    -- ^ an S3-compatible endpoint, path-style
    , optResolver :: Dns.Resolver
    }

defaultOptions :: Options
defaultOptions = Options Http.defaultOptions Nothing Nothing Nothing Dns.digResolver

{- | Where a git registry is checked out when nobody said: @checkout@ under
the cache directory (the one place a follower already keeps state across
restarts), else a directory under the system's temporary one named by the
repository, so that two followers of different repositories on one machine
do not share a checkout. -}
defaultWorkdir :: Maybe FilePath -> Git.Source -> IO FilePath
defaultWorkdir (Just cache) _ = pure (cache </> "checkout")
defaultWorkdir Nothing source = do
    tmp <- getTemporaryDirectory
    pure (tmp </> ("salmon-follow-" <> Text.unpack (Text.take 12 (unDigest (digestOf (LChar8.pack (Text.unpack (Git.renderSource source))))))))

-- | The registry for an address.
open :: Options -> Address -> IO Registry
open o addr = case addr of
    Directory dir -> pure (directoryRegistry dir)
    Git source -> do
        workdir <- maybe (defaultWorkdir o.optCacheDir source) pure o.optWorkdir
        Git.gitRegistry workdir source
    Http template -> do
        mgr <- Http.newManager o.optHttp
        pure (Http.httpRegistry mgr template)
    Dns zone -> do
        mgr <- Http.newManager o.optHttp
        pure (Dns.dnsRegistry o.optResolver mgr zone)
    InBucket b -> do
        mgr <- Http.newManager o.optHttp
        let reg = Http.httpRegistry mgr (bucketTemplate o.optBucketEndpoint b)
        pure reg{registryName = renderBucket b}

-- | The address back, as given: what @history@ and reports name.
renderBucket :: Bucket -> Text
renderBucket b = (case b.bucketStore of S3 -> "s3://"; Gcs -> "gs://") <> b.bucketName <> (if Text.null b.bucketPrefix then "" else "/" <> b.bucketPrefix)