packages feed

clickhouse-migrate-0.2.0.0: src/ClickHouseMigrate/Target.hs

-- | Explicit, validated configuration for one direct administrative target.
module ClickHouseMigrate.Target
  ( TargetKey,
    targetKey,
    targetKeyText,
    DirectEndpoint,
    directEndpoint,
    endpointText,
    ServerId,
    serverId,
    serverIdText,
    QueryId,
    queryId,
    queryIdText,
    LedgerId,
    ledgerId,
    newLedgerId,
    ledgerIdText,
    LedgerNamespace,
    ledgerNamespace,
    ledgerNamespaceText,
    LedgerBinding (FirstUse, ExistingLedger),
    Credentials,
    basicCredentials,
    HttpLimits,
    httpLimits,
    defaultHttpLimits,
    TargetConfig,
    targetConfig,
    TargetTopology (DirectServer, ReplicatedCluster),
    ClusterName,
    clusterName,
    clusterNameText,
    replicatedTargetConfig,
    configuredTopology,
    configuredTargetKey,
    configuredLedgerBinding,
    TargetError (..),
    ServerIdentity,
    identityServerId,
    identityVersion,
    identityHostName,
  )
where

import ClickHouseMigrate.Internal.Identity (freshUuid)
import ClickHouseMigrate.Internal.TargetTypes
import ClickHouseMigrate.Prelude
import Data.Char (isAsciiLower, isAsciiUpper)
import Data.List.NonEmpty qualified as NE
import Data.Set qualified as Set
import Data.Text qualified as Text
import Data.Text.Encoding qualified as Encoding
import Text.Read (readMaybe)

data TargetError = InvalidTargetKey | InvalidEndpoint | InvalidServerId | InvalidQueryId | InvalidLedgerId | InvalidLedgerNamespace | InvalidCredentials | InvalidHttpLimits | InvalidClusterName | DuplicateReplica
  deriving stock (Eq, Ord, Show)

targetKey :: Text -> Either TargetError TargetKey
targetKey value
  | valid 256 (\c -> lowerDigit c || c `elem` ("/_:.-" :: String)) value && maybe False (lowerDigit . fst) (Text.uncons value) = Right (TargetKey value)
  | otherwise = Left InvalidTargetKey

directEndpoint :: Text -> Either TargetError DirectEndpoint
directEndpoint value = do
  authority <- case Text.stripPrefix "http://" value of
    Just rest -> Right rest
    Nothing -> maybe (Left InvalidEndpoint) Right (Text.stripPrefix "https://" value)
  let hostPort = maybe authority id (Text.stripSuffix "/" authority)
      validHost c = isAsciiLower c || isAsciiUpper c || digit c || c `elem` (".-" :: String)
      validPort "" = True
      validPort suffix = case Text.stripPrefix ":" suffix of
        Just port | not (Text.null port) && Text.all digit port -> case readMaybe (Text.unpack port) :: Maybe Int of
          Just n -> n > 0 && n <= 65535
          _ -> False
        _ -> False
      validAuthority = case Text.stripPrefix "[" hostPort of
        Just ipv6 -> case Text.breakOn "]" ipv6 of
          (host, suffix) -> not (Text.null host) && Text.all (\c -> digit c || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F') || c `elem` (":." :: String)) host && maybe False validPort (Text.stripPrefix "]" suffix)
        Nothing -> let (host, port) = Text.breakOn ":" hostPort in not (Text.null host) && Text.all validHost host && validPort port
  if Text.length value <= 2048 && validAuthority then Right (DirectEndpoint value) else Left InvalidEndpoint

serverId :: Text -> Either TargetError ServerId
serverId value = if uuid value then Right (ServerId value) else Left InvalidServerId

queryId :: Text -> Either TargetError QueryId
queryId value = if valid 128 (\c -> lowerDigit c || isAsciiUpper c || c `elem` ("_-" :: String)) value then Right (QueryId value) else Left InvalidQueryId

ledgerId :: Text -> Either TargetError LedgerId
ledgerId value = if uuid value then Right (LedgerId value) else Left InvalidLedgerId

ledgerNamespace :: Text -> Either TargetError LedgerNamespace
ledgerNamespace value
  | valid 128 (\c -> lowerDigit c || c == '_') value && maybe False (\(c, _) -> isAsciiLower c || c == '_') (Text.uncons value) = Right (LedgerNamespace value)
  | otherwise = Left InvalidLedgerNamespace

basicCredentials :: Text -> Text -> Either TargetError Credentials
basicCredentials user password
  | Text.null user || Text.any (`elem` (":\r\n\0" :: String)) user || Text.any (== '\0') password = Left InvalidCredentials
  | otherwise = Right (Credentials (Encoding.encodeUtf8 user) (Encoding.encodeUtf8 password))

-- | Deadline and raw-byte bound. Ordinary responses are bounded in total;
-- complete ledger reads apply this byte bound per JSON row (excluding LF).
-- Decoded history and reports still retain memory proportional to history size.
httpLimits :: Int -> Int -> Either TargetError HttpLimits
httpLimits timeoutMicros maxBytes
  | timeoutMicros > 0 && timeoutMicros <= 300000000 && maxBytes > 0 && maxBytes <= 16777216 = Right (HttpLimits timeoutMicros maxBytes)
  | otherwise = Left InvalidHttpLimits

defaultHttpLimits :: HttpLimits
defaultHttpLimits = HttpLimits 30000000 1048576

targetConfig :: TargetKey -> DirectEndpoint -> ServerId -> LedgerNamespace -> LedgerBinding -> Credentials -> HttpLimits -> TargetConfig
targetConfig key endpoint identity = TargetConfig key endpoint (DirectServer identity)

targetKeyText :: TargetKey -> Text
targetKeyText (TargetKey value) = value

endpointText :: DirectEndpoint -> Text
endpointText (DirectEndpoint value) = value

serverIdText :: ServerId -> Text
serverIdText (ServerId value) = value

queryIdText :: QueryId -> Text
queryIdText (QueryId value) = value

ledgerIdText :: LedgerId -> Text
ledgerIdText (LedgerId value) = value

ledgerNamespaceText :: LedgerNamespace -> Text
ledgerNamespaceText (LedgerNamespace value) = value

valid :: Int -> (Char -> Bool) -> Text -> Bool
valid limit allowed value = not (Text.null value) && Text.length value <= limit && Text.all allowed value

lowerDigit :: Char -> Bool
lowerDigit c = isAsciiLower c || digit c

digit :: Char -> Bool
digit c = c >= '0' && c <= '9'

uuid :: Text -> Bool
uuid value = map Text.length (Text.splitOn "-" value) == [8, 4, 4, 4, 12] && Text.all (\c -> c == '-' || digit c || (c >= 'a' && c <= 'f')) value

identityServerId :: ServerIdentity -> ServerId
identityServerId (ServerIdentity value _ _) = value

identityVersion :: ServerIdentity -> Text
identityVersion (ServerIdentity _ value _) = value

identityHostName :: ServerIdentity -> Text
identityHostName (ServerIdentity _ _ value) = value

-- | Fresh client-generated UUID for a one-time first-use authorization. Persist
-- this value before submission and retain it after interrupted initialization.
newLedgerId :: IO LedgerId
newLedgerId = LedgerId <$> freshUuid

-- | Read-only nonsecret configuration identity for application adapters.
configuredTargetKey :: TargetConfig -> TargetKey
configuredTargetKey = configKey

-- | Binding mode is explicit; this accessor grants no initialization authority.
configuredLedgerBinding :: TargetConfig -> LedgerBinding
configuredLedgerBinding = configBinding

-- | The admitted server topology, without credentials.
configuredTopology :: TargetConfig -> TargetTopology
configuredTopology = configTopology

clusterName :: Text -> Either TargetError ClusterName
clusterName value
  | valid 128 (\c -> lowerDigit c || c `elem` ("_-" :: String)) value && maybe False (\(c, _) -> isAsciiLower c || c == '_') (Text.uncons value) = Right (ClusterName value)
  | otherwise = Left InvalidClusterName

clusterNameText :: ClusterName -> Text
clusterNameText (ClusterName value) = value

-- | Every expected replica participates in acknowledged writes. The deadline must
-- leave room for bounded server waits and a complete HTTP error response.
replicatedTargetConfig :: TargetKey -> DirectEndpoint -> ClusterName -> NonEmpty ServerId -> LedgerNamespace -> LedgerBinding -> Credentials -> HttpLimits -> Either TargetError TargetConfig
replicatedTargetConfig key endpoint cluster replicas namespace binding credentials limits@(HttpLimits micros _)
  | Set.size (Set.fromList (NE.toList replicas)) /= NE.length replicas = Left DuplicateReplica
  | micros < 5000000 = Left InvalidHttpLimits
  | otherwise = Right (TargetConfig key endpoint (ReplicatedCluster cluster replicas) namespace binding credentials limits)