packages feed

hetzner 0.4.0.1 → 0.5.0.0

raw patch · 6 files changed

+494/−39 lines, 6 filesPVP ok

version bump matches the API change (PVP)

API changes (from Hackage documentation)

- Hetzner.Cloud: LabelAll :: [LabelSelector] -> LabelSelector
- Hetzner.Cloud: instance GHC.Base.Monoid Hetzner.Cloud.LabelSelector
- Hetzner.Cloud: instance GHC.Base.Semigroup Hetzner.Cloud.LabelSelector
- Hetzner.Cloud.Fingerprint: fingerprintParser :: Parser Fingerprint
- Hetzner.Cloud.Fingerprint: instance Data.Aeson.Types.FromJSON.FromJSON GHC.Fingerprint.Type.Fingerprint
+ Hetzner.Cloud: CreatedFirewall :: [Action] -> Firewall -> CreatedFirewall
+ Hetzner.Cloud: Firewall :: FirewallID -> Text -> ZonedTime -> [ServerID] -> [LabelSelectorAll] -> [FirewallRule] -> LabelMap -> Firewall
+ Hetzner.Cloud: FirewallRule :: Maybe Text -> TrafficDirection -> NonEmpty (Either IPv4Range IPv6Range) -> FirewallRuleProtocol -> FirewallRule
+ Hetzner.Cloud: FirewallRuleESP :: FirewallRuleProtocol
+ Hetzner.Cloud: FirewallRuleGRE :: FirewallRuleProtocol
+ Hetzner.Cloud: FirewallRuleICMP :: FirewallRuleProtocol
+ Hetzner.Cloud: FirewallRuleTCP :: PortRange -> FirewallRuleProtocol
+ Hetzner.Cloud: FirewallRuleUDP :: PortRange -> FirewallRuleProtocol
+ Hetzner.Cloud: LabelSelectorAll :: [LabelSelector] -> LabelSelectorAll
+ Hetzner.Cloud: NewFirewall :: Text -> [Label] -> [FirewallRule] -> [ServerID] -> [LabelSelectorAll] -> NewFirewall
+ Hetzner.Cloud: PortRange :: Int -> Int -> PortRange
+ Hetzner.Cloud: ResourceFirewallID :: FirewallID -> ResourceID
+ Hetzner.Cloud: TrafficIn :: TrafficDirection
+ Hetzner.Cloud: TrafficOut :: TrafficDirection
+ Hetzner.Cloud: [createdFirewallActions] :: CreatedFirewall -> [Action]
+ Hetzner.Cloud: [createdFirewall] :: CreatedFirewall -> Firewall
+ Hetzner.Cloud: [firewallCreated] :: Firewall -> ZonedTime
+ Hetzner.Cloud: [firewallID] :: Firewall -> FirewallID
+ Hetzner.Cloud: [firewallLabelSelectors] :: Firewall -> [LabelSelectorAll]
+ Hetzner.Cloud: [firewallLabels] :: Firewall -> LabelMap
+ Hetzner.Cloud: [firewallName] :: Firewall -> Text
+ Hetzner.Cloud: [firewallRuleDescription] :: FirewallRule -> Maybe Text
+ Hetzner.Cloud: [firewallRuleDirection] :: FirewallRule -> TrafficDirection
+ Hetzner.Cloud: [firewallRuleIPs] :: FirewallRule -> NonEmpty (Either IPv4Range IPv6Range)
+ Hetzner.Cloud: [firewallRuleProtocol] :: FirewallRule -> FirewallRuleProtocol
+ Hetzner.Cloud: [firewallRules] :: Firewall -> [FirewallRule]
+ Hetzner.Cloud: [firewallServers] :: Firewall -> [ServerID]
+ Hetzner.Cloud: [newFirewallLabelSelectors] :: NewFirewall -> [LabelSelectorAll]
+ Hetzner.Cloud: [newFirewallLabels] :: NewFirewall -> [Label]
+ Hetzner.Cloud: [newFirewallName] :: NewFirewall -> Text
+ Hetzner.Cloud: [newFirewallRules] :: NewFirewall -> [FirewallRule]
+ Hetzner.Cloud: [newFirewallServers] :: NewFirewall -> [ServerID]
+ Hetzner.Cloud: [serverIncludedTraffic] :: ServerType -> Int
+ Hetzner.Cloud: anyIPv4 :: IPv4Range
+ Hetzner.Cloud: anyIPv6 :: IPv6Range
+ Hetzner.Cloud: applyFirewall :: Token -> FirewallID -> [ServerID] -> [LabelSelectorAll] -> IO [Action]
+ Hetzner.Cloud: createFirewall :: Token -> NewFirewall -> IO CreatedFirewall
+ Hetzner.Cloud: data CreatedFirewall
+ Hetzner.Cloud: data Firewall
+ Hetzner.Cloud: data FirewallRule
+ Hetzner.Cloud: data FirewallRuleProtocol
+ Hetzner.Cloud: data NewFirewall
+ Hetzner.Cloud: data PortRange
+ Hetzner.Cloud: data TrafficDirection
+ Hetzner.Cloud: defaultNewFirewall :: Text -> NewFirewall
+ Hetzner.Cloud: deleteFirewall :: Token -> FirewallID -> IO ()
+ Hetzner.Cloud: getFirewall :: Token -> FirewallID -> IO Firewall
+ Hetzner.Cloud: getFirewalls :: Token -> Maybe Int -> IO (WithMeta "firewalls" [Firewall])
+ Hetzner.Cloud: instance (Data.Aeson.Types.FromJSON.FromJSON a, Data.Aeson.Types.FromJSON.FromJSON b) => Data.Aeson.Types.FromJSON.FromJSON (Hetzner.Cloud.EitherParser a b)
+ Hetzner.Cloud: instance (Data.Aeson.Types.ToJSON.ToJSON a, Data.Aeson.Types.ToJSON.ToJSON b) => Data.Aeson.Types.ToJSON.ToJSON (Hetzner.Cloud.EitherParser a b)
+ Hetzner.Cloud: instance Data.Aeson.Types.FromJSON.FromJSON Hetzner.Cloud.CreatedFirewall
+ Hetzner.Cloud: instance Data.Aeson.Types.FromJSON.FromJSON Hetzner.Cloud.Firewall
+ Hetzner.Cloud: instance Data.Aeson.Types.FromJSON.FromJSON Hetzner.Cloud.FirewallRule
+ Hetzner.Cloud: instance Data.Aeson.Types.FromJSON.FromJSON Hetzner.Cloud.LabelSelectorAll
+ Hetzner.Cloud: instance Data.Aeson.Types.FromJSON.FromJSON Hetzner.Cloud.PortRange
+ Hetzner.Cloud: instance Data.Aeson.Types.FromJSON.FromJSON Hetzner.Cloud.TrafficDirection
+ Hetzner.Cloud: instance Data.Aeson.Types.ToJSON.ToJSON Hetzner.Cloud.FirewallRule
+ Hetzner.Cloud: instance Data.Aeson.Types.ToJSON.ToJSON Hetzner.Cloud.LabelSelectorAll
+ Hetzner.Cloud: instance Data.Aeson.Types.ToJSON.ToJSON Hetzner.Cloud.PortRange
+ Hetzner.Cloud: instance Data.Aeson.Types.ToJSON.ToJSON Hetzner.Cloud.TrafficDirection
+ Hetzner.Cloud: instance GHC.Classes.Eq Hetzner.Cloud.TrafficDirection
+ Hetzner.Cloud: instance GHC.Show.Show Hetzner.Cloud.CreatedFirewall
+ Hetzner.Cloud: instance GHC.Show.Show Hetzner.Cloud.Firewall
+ Hetzner.Cloud: instance GHC.Show.Show Hetzner.Cloud.FirewallRule
+ Hetzner.Cloud: instance GHC.Show.Show Hetzner.Cloud.FirewallRuleProtocol
+ Hetzner.Cloud: instance GHC.Show.Show Hetzner.Cloud.LabelSelector
+ Hetzner.Cloud: instance GHC.Show.Show Hetzner.Cloud.LabelSelectorAll
+ Hetzner.Cloud: instance GHC.Show.Show Hetzner.Cloud.PortRange
+ Hetzner.Cloud: instance GHC.Show.Show Hetzner.Cloud.TrafficDirection
+ Hetzner.Cloud: newtype LabelSelectorAll
+ Hetzner.Cloud: removeFirewall :: Token -> FirewallID -> [ServerID] -> [LabelSelectorAll] -> IO [Action]
+ Hetzner.Cloud: updateFirewall :: Token -> FirewallID -> Text -> [Label] -> IO Firewall
+ Hetzner.Cloud.Fingerprint: FingerprintText :: Fingerprint -> FingerprintText
+ Hetzner.Cloud.Fingerprint: [fingerprint] :: FingerprintText -> Fingerprint
+ Hetzner.Cloud.Fingerprint: instance Data.Aeson.Types.FromJSON.FromJSON Hetzner.Cloud.Fingerprint.FingerprintText
+ Hetzner.Cloud.Fingerprint: newtype FingerprintText
- Hetzner.Cloud: ServerType :: Architecture -> Int -> CPUType -> Bool -> Text -> Int -> ServerTypeID -> Int -> Text -> [PriceInLocation] -> StorageType -> ServerType
+ Hetzner.Cloud: ServerType :: Architecture -> Int -> CPUType -> Bool -> Text -> Int -> ServerTypeID -> Int -> Text -> [PriceInLocation] -> StorageType -> Int -> ServerType

Files

apps/Docs.hs view
@@ -47,6 +47,7 @@           H.th "Cores"           H.th "Memory"           H.th "Disk"+          H.th "Included Traffic (TB)"           H.th "CPU Type"           H.th "Max Monthly Price"         forM_ serverTypes $ \serverType -> H.tr $ do@@ -56,6 +57,8 @@           H.td $ toHtml $ Hetzner.serverCores serverType           H.td $ toHtml $ show (Hetzner.serverMemory serverType) ++ " GB"           H.td $ toHtml $ show (Hetzner.serverDisk serverType) ++ " GB"+          let terabyte = 1024 ^ (4 :: Int)+          H.td $ toHtml $ show (Hetzner.serverIncludedTraffic serverType `div` terabyte) ++ " TB"           H.td $ toHtml $ case Hetzner.serverCPUType serverType of             Hetzner.SharedCPU -> "Shared" :: String             Hetzner.DedicatedCPU -> "Dedicated"
changelog.md view
@@ -1,3 +1,10 @@+## 0.5.0.0+* Do not export internal fingerprint parser.+* Wrap `Fingerprint` with a newtype to provide custom+  `FromJSON` instance.+* Firewall support.+* Fix label parsing and rendering.+ ## 0.4.0.1 * Update links in documentation. 
hetzner.cabal view
@@ -2,7 +2,7 @@ category: Cloud synopsis: Hetzner Cloud and DNS library. description: Hetzner Cloud and DNS library. Check the readme and documentation for more details.-version: 0.4.0.1+version: 0.5.0.0 cabal-version: 1.18 build-type: Simple author: Daniel Casanueva (daniel.casanueva `at` proton.me)
readme.md view
@@ -10,7 +10,7 @@  This library is still in development, so expect missing funcionality. If there's anything you would like to see added, feel free to-[open an issue](https://github.com/Daniel-Diaz/hetzner/issues/new).+[open an issue](https://gitlab.com/daniel-casanueva/haskell/hetzner/-/issues/new). Some breaking changes might also be introduced as the library matures.  ## Documentation
src/Hetzner/Cloud.hs view
@@ -62,6 +62,24 @@   , getDatacenter     -- ** Firewalls   , FirewallID (..)+  , TrafficDirection (..)+  , PortRange (..)+  , FirewallRuleProtocol (..)+  , FirewallRule (..)+  , anyIPv4+  , anyIPv6+  , Firewall (..)+  , NewFirewall (..)+  , defaultNewFirewall+  , CreatedFirewall (..)+  , getFirewalls+  , getFirewall+  , createFirewall+  , deleteFirewall+  , updateFirewall+    -- *** Firewall actions+  , applyFirewall+  , removeFirewall     -- ** Floating IPs   , FloatingIPID (..)     -- ** Images@@ -157,6 +175,7 @@   , toLabelMap   , fromLabelMap   , LabelSelector (..)+  , LabelSelectorAll (..)     -- * Other types     -- ** Regions   , Region (..)@@ -181,7 +200,7 @@   , Pagination (..)     ) where -import Hetzner.Cloud.Fingerprint (Fingerprint)+import Hetzner.Cloud.Fingerprint (Fingerprint, fingerprint) -- base import Control.Exception (Exception, throwIO) import Control.Concurrent (threadDelay)@@ -189,16 +208,22 @@ import Data.Proxy import Data.String (IsString, fromString) import Data.Void+import Data.Either (partitionEithers) #if !MIN_VERSION_base(4,18,0) import Control.Applicative (liftA2) #endif+import Control.Applicative ((<|>)) import Control.Monad.IO.Class (MonadIO, liftIO) import Data.Foldable (forM_)+import Data.Traversable (forM) import Data.Maybe (isNothing, fromMaybe) import System.Environment qualified as System+import Data.List.NonEmpty (NonEmpty ((:|))) -- ip import Net.IPv4 (IPv4, IPv4Range)+import Net.IPv4 qualified as IPv4 import Net.IPv6 (IPv6, IPv6Range)+import Net.IPv6 qualified as IPv6 -- bytestring import Data.ByteString (ByteString) -- text@@ -224,6 +249,7 @@ import Country (Country) -- megaparsec import Text.Megaparsec qualified as Parser+import Text.Megaparsec.Char qualified as Parser import Text.Megaparsec.Char.Lexer qualified as Parser -- containers import Data.Map (Map)@@ -272,16 +298,36 @@  type Parser = Parser.Parsec Void Text +labelKeyPrefixParser :: Parser Text+labelKeyPrefixParser = do+  xs <- Parser.sepBy1 (Text.pack <$> Parser.some Parser.alphaNumChar) (Parser.single '.')+  _ <- Parser.single '/'+  pure $ Text.intercalate "." xs++labelKeyNameParser :: Parser Text+labelKeyNameParser = do+  x <- Parser.alphaNumChar+  let loop :: Bool -> Parser [Char]+      loop afterSymbol = Parser.choice+        [ (:) <$> Parser.alphaNumChar <*> loop False+        , (:) <$> Parser.single '-' <*> loop True+        , (:) <$> Parser.single '_' <*> loop True+        , (:) <$> Parser.single '.' <*> loop True+        , if afterSymbol+             then fail "Label key name must end in alphanumeric character."+             else pure []+          ]+  xs <- loop False+  pure $ Text.pack $ x : xs+ labelKeyParser :: Parser LabelKey labelKeyParser = do-  prefix <- fmap Text.pack $ Parser.some $ Parser.anySingleBut '/'-  atEnd <- Parser.atEnd-  if atEnd-     then pure $ LabelKey Nothing prefix-     else LabelKey (Just prefix) <$> (Parser.single '/' *> Parser.takeRest)+  prefix <- Parser.optional $ Parser.try labelKeyPrefixParser+  name <- labelKeyNameParser+  pure $ LabelKey prefix name -renderKeyParser :: LabelKey -> Text-renderKeyParser k = case labelKeyPrefix k of+labelKeyRender :: LabelKey -> Text+labelKeyRender k = case labelKeyPrefix k of   Just prefix -> Text.concat [ prefix, "/", labelKeyName k ]   _ -> labelKeyName k @@ -291,18 +337,18 @@       Parser.runParser labelKeyParser "JSON" t  instance ToJSON LabelKey where-   toJSON = JSON.String . renderKeyParser+   toJSON = JSON.String . labelKeyRender  instance FromJSONKey LabelKey where   fromJSONKey = JSON.FromJSONKeyTextParser $ \t ->     either (fail . Parser.errorBundlePretty) pure $-      Parser.runParser labelKeyParser "JSON key" t+      Parser.runParser (labelKeyParser <* Parser.eof) "JSON key" t  instance ToJSONKey LabelKey where   toJSONKey =     JSON.ToJSONKeyText-      (JSONKey.fromText . renderKeyParser)-      (JSONEncoding.text . renderKeyParser)+      (JSONKey.fromText . labelKeyRender)+      (JSONEncoding.text . labelKeyRender)  -- | Labels are key-value pairs that can be attached to all resources. data Label = Label@@ -310,6 +356,23 @@   , labelValue :: Text     } deriving (Eq, Show) +labelValueParser :: Parser Text+labelValueParser = do+  let loop :: Bool -> Parser [Char]+      loop afterSymbol = Parser.choice+        [ (:) <$> Parser.alphaNumChar <*> loop False+        , (:) <$> Parser.single '-' <*> loop True+        , (:) <$> Parser.single '_' <*> loop True+        , (:) <$> Parser.single '.' <*> loop True+        , if afterSymbol+             then fail "Label value must end in alphanumeric character."+             else pure []+          ]+  mx <- Parser.optional Parser.alphaNumChar+  case mx of+    Just x -> Text.pack . (:) x <$> loop False+    _ -> pure mempty+ -- | A label map maps label keys to values. type LabelMap = Map LabelKey Text @@ -321,7 +384,7 @@ fromLabelMap :: LabelMap -> [Label] fromLabelMap = Map.foldrWithKey (\k v xs -> Label k v : xs) [] --- | Label selectors can be used to filter results.+-- | Label selectors can be used to filter resources. data LabelSelector =     -- | Select when label is equal.     LabelEqual Label@@ -335,26 +398,63 @@   | KeyValueIn LabelKey [Text]     -- | Select when label has none of the values.   | KeyValueNotIn LabelKey [Text]-    -- | Select only when all selectors succeed.-  | LabelAll [LabelSelector]+    deriving Show --- | Semigroup under /\"and\"/ operation.-instance Semigroup LabelSelector where-  LabelAll xs <> LabelAll ys = LabelAll (xs ++ ys)-  LabelAll xs <> s =-    case xs of-      [] -> s-      _  -> LabelAll (xs ++ [s])-  s <> LabelAll xs =-    case xs of-      [] -> s-      _  -> LabelAll (s : xs)-  s <> s' = LabelAll [s,s']+-- | Label selector parser.+labelSelectorParser :: Parser LabelSelector+labelSelectorParser = Parser.choice+  [ Parser.single '!' *> (KeyNotPresent <$> labelKeyParser)+  , do k <- labelKeyParser+       Parser.choice+         [ do _ <- Parser.single '='+              _ <- Parser.optional $ Parser.single '='+              v <- labelValueParser+              pure $ LabelEqual $ Label k v+         , do _ <- Parser.single '!'+              _ <- Parser.single '='+              v <- labelValueParser+              pure $ LabelNotEqual $ Label k v+         , do _ <- Parser.single ' '+              Parser.choice+                [ do _ <- Parser.chunk "in ("+                     vs <- Parser.sepBy1 labelValueParser $ Parser.single ','+                     _ <- Parser.single ')'+                     pure $ KeyValueIn k vs+                , do _ <- Parser.chunk "notin ("+                     vs <- Parser.sepBy1 labelValueParser $ Parser.single ','+                     _ <- Parser.single ')'+                     pure $ KeyValueNotIn k vs+                  ]+         , pure $ KeyPresent k+           ]+    ] --- | Neutral element is a selector that always succeeds.-instance Monoid LabelSelector where-  mempty = LabelAll []+renderLabelSelector :: LabelSelector -> Text+renderLabelSelector (LabelEqual l) =+  labelKeyRender (labelKey l) <> "==" <> labelValue l+renderLabelSelector (LabelNotEqual l) =+  labelKeyRender (labelKey l) <> "!=" <> labelValue l+renderLabelSelector (KeyPresent k) = labelKeyRender k+renderLabelSelector (KeyNotPresent k) = "!" <> labelKeyRender k+renderLabelSelector (KeyValueIn k vs) =+  labelKeyRender k <> " in (" <> Text.intercalate "," vs <> ")"+renderLabelSelector (KeyValueNotIn k vs) =+  labelKeyRender k <> " notin (" <> Text.intercalate "," vs <> ")" +-- | Combine a list of label selectors, giving you a selector that+--   selects labels that match /all/ selectors in the list.+newtype LabelSelectorAll = LabelSelectorAll [LabelSelector] deriving Show++instance FromJSON LabelSelectorAll where+  parseJSON = JSON.withText "LabelSelector" $ \t ->+    either (fail . Parser.errorBundlePretty) pure $+      let parser = fmap LabelSelectorAll $ Parser.sepBy1 labelSelectorParser $ Parser.single ','+      in  Parser.runParser (parser <* Parser.eof) "JSON" t++instance ToJSON LabelSelectorAll where+  toJSON (LabelSelectorAll xs) = JSON.String $+    Text.intercalate "," $ fmap renderLabelSelector xs+ -- | Pagination information. data Pagination = Pagination   { currentPage :: Int@@ -623,6 +723,27 @@   parseJSON = JSON.withObject "ResponseMeta" $ \o ->     ResponseMeta <$> o .: "pagination" +-- | Equivalent to 'Either', but with a different json serialization.+data EitherParser a b = LeftParser a | RightParser b++-- | Equivalent of 'either' for 'EitherParser'.+eitherParser :: (a -> c) -> (b -> c) -> EitherParser a b -> c+eitherParser f _ (LeftParser a) = f a+eitherParser _ g (RightParser b) = g b++instance (FromJSON a, FromJSON b) => FromJSON (EitherParser a b) where+  parseJSON v = fmap LeftParser (JSON.parseJSON v) <|> fmap RightParser (JSON.parseJSON v)++instance (ToJSON a, ToJSON b) => ToJSON (EitherParser a b) where+  toJSON (LeftParser a) = JSON.toJSON a+  toJSON (RightParser b) = JSON.toJSON b++toEitherParser :: Either a b -> EitherParser a b+toEitherParser = either LeftParser RightParser++fromEitherParser :: EitherParser a b -> Either a b+fromEitherParser = eitherParser Left Right+ ---------------------------------------------------------------------------------------------------- -- Actions ----------------------------------------------------------------------------------------------------@@ -681,6 +802,8 @@   | ResourceVolumeID VolumeID     -- | Primary IP ID.   | ResourcePrimaryIPID PrimaryIPID+    -- | Firewall ID.+  | ResourceFirewallID FirewallID     deriving Show  instance FromJSON ResourceID where@@ -690,6 +813,7 @@       "server" -> ResourceServerID <$> o .: "id"       "volume" -> ResourceVolumeID <$> o .: "id"       "primary_ip" -> ResourcePrimaryIPID <$> o .: "id"+      "firewall" -> ResourceFirewallID <$> o .: "id"       _ -> fail $ "Unknown resource type: " ++ Text.unpack t  -- | Action.@@ -730,7 +854,7 @@     go :: IO ZonedTime     go = do action <- getAction token i             case actionStatus action of-              ActionRunning _ -> threadDelay 200000 *> go+              ActionRunning _ -> threadDelay 250000 *> go               ActionSuccess t -> pure t               ActionError _ err -> throwIO $ CloudError err @@ -800,6 +924,317 @@ -- | Firewall identifier. newtype FirewallID = FirewallID Int deriving (Eq, Ord, Show, FromJSON, ToJSON) +-- | Traffic direction, whether incoming ('TrafficIn') or outgoing ('TrafficOut').+data TrafficDirection = TrafficIn | TrafficOut deriving (Eq, Show)++instance FromJSON TrafficDirection where+  parseJSON = JSON.withText "TrafficDirection" $ \t ->+    case t of+      "in" -> pure TrafficIn+      "out" -> pure TrafficOut+      _ -> fail $ "Invalid traffic direction: " ++ Text.unpack t++instance ToJSON TrafficDirection where+  toJSON TrafficIn = JSON.String "in"+  toJSON TrafficOut = JSON.String "out"++-- | A port range. It can contain only one port if both ends are the same.+data PortRange = PortRange Int Int deriving Show++-- | A port range containing a single port.+singlePort :: Int -> PortRange+singlePort p = PortRange p p++portRangeParser :: Parser PortRange+portRangeParser = do+  p1 <- Parser.decimal+  hasSecondPart <- (Parser.single '-' *> pure True) <|> pure False+  if hasSecondPart+     then PortRange p1 <$> Parser.decimal+     else pure $ PortRange p1 p1++instance FromJSON PortRange where+  parseJSON = JSON.withText "PortRange" $ \t ->+    either (fail . Parser.errorBundlePretty) pure $+      Parser.runParser (portRangeParser <* Parser.eof) "JSON" t++instance ToJSON PortRange where+  toJSON (PortRange p1 p2) = fromString $+    if p1 == p2+       then show p1+       else show p1 ++ "-" ++ show p2++-- | Protocol used in a 'FirewallRule'.+data FirewallRuleProtocol =+    -- | TCP protocol on the given port range.+    FirewallRuleTCP PortRange+    -- | UDP protocol on the given port range.+  | FirewallRuleUDP PortRange+    -- | ICMP protocol.+  | FirewallRuleICMP+    -- | ESP protocol.+  | FirewallRuleESP+    -- | GRE protocol.+  | FirewallRuleGRE+    deriving Show++-- | A firewall rule.+data FirewallRule = FirewallRule+  { -- | Optional description of the rule.+    firewallRuleDescription :: Maybe Text+    -- | Traffic direction the rule applies to.+  , firewallRuleDirection :: TrafficDirection+    -- | IPs the rule applies to. You can use 'anyIPv4' and/or+    --   'anyIPv6' to allow any IPs.+  , firewallRuleIPs :: NonEmpty (Either IPv4Range IPv6Range)+    -- | Protocol the rule applies to.+  , firewallRuleProtocol :: FirewallRuleProtocol+    } deriving Show++instance FromJSON FirewallRule where+  parseJSON = JSON.withObject "FirewallRule" $ \o -> do+    dir <- o .: "direction"+    ips <-+      case dir of+        TrafficIn -> o .: "source_ips"+        TrafficOut -> o .: "destination_ips"+    protocolType <- o .: "protocol"+    protocol <-+      case protocolType of+        "tcp" -> FirewallRuleTCP <$> o .: "port"+        "udp" -> FirewallRuleUDP <$> o .: "port"+        "icmp" -> pure FirewallRuleICMP+        "esp" -> pure FirewallRuleESP+        "gre" -> pure FirewallRuleGRE+        _ -> fail $ "Invalid protocol: " ++ Text.unpack protocolType+    FirewallRule+      <$> o .:? "description"+      <*> pure dir+      <*> pure (fmap fromEitherParser ips)+      <*> pure protocol++instance ToJSON FirewallRule where+  toJSON rule =+    let dir = firewallRuleDirection rule+        ips = fmap toEitherParser $ firewallRuleIPs rule+    in  JSON.object $+          maybe [] (\t -> pure $ "description" .= t) (firewallRuleDescription rule)+            ++ [ "direction" .= dir+               , case dir of+                   TrafficIn -> "source_ips" .= ips+                   TrafficOut -> "destination_ips" .= ips+                 ]+            ++ (case firewallRuleProtocol rule of+                  FirewallRuleTCP r ->+                    [ "protocol" .= ("tcp" :: Text)+                    , "port" .= r+                      ]+                  FirewallRuleUDP r ->+                    [ "protocol" .= ("udp" :: Text)+                    , "port" .= r+                      ]+                  FirewallRuleICMP -> [ "protocol" .= ("icmp" :: Text) ]+                  FirewallRuleESP -> [ "protocol" .= ("esp" :: Text) ]+                  FirewallRuleGRE -> [ "protocol" .= ("gre" :: Text) ]+                 )++-- | A firewall that can be applied to other resources, via 'applyFirewall'+--   or directly on creation.+data Firewall = Firewall+  { firewallID :: FirewallID+    -- | The firewall's name.+  , firewallName :: Text+    -- | Time the firewall was created.+  , firewallCreated :: ZonedTime+    -- | Servers the firewall has been applied to.+  , firewallServers :: [ServerID]+    -- | Label selectors used to apply the firewall automatically to+    --   matching resources.+  , firewallLabelSelectors :: [LabelSelectorAll]+    -- | Firewall rules.+  , firewallRules :: [FirewallRule]+    -- | Labels attached to the firewall.+  , firewallLabels :: LabelMap+    } deriving Show++instance FromJSON Firewall where+  parseJSON = JSON.withObject "Firewall" $ \o -> do+    xs <- o .: "applied_to"+    ys <- forM xs $ \v ->+            let f o' = do+                  t <- o' .: "type"+                  case t of+                    "server" -> do+                       v' <- o' .: "server"+                       Left <$> JSON.withObject "Server" (.: "id") v'+                    "label_selector" -> do+                       v' <- o' .: "label_selector"+                       Right <$> JSON.withObject "LabelSelector" (.: "selector") v'+                    _ -> fail $ "Invalid applied_to type: " ++ Text.unpack t+            in  JSON.withObject "AppliedTo" f v+    let (servers, labels) = partitionEithers ys+    Firewall+      <$> o .: "id"+      <*> o .: "name"+      <*> o .: "created"+      <*> pure servers+      <*> pure labels+      <*> o .: "rules"+      <*> o .: "labels"++-- | Information used to create a new firewall with 'createFirewall'.+data NewFirewall = NewFirewall+  { -- | The firewall's name.+    newFirewallName :: Text+    -- | Labels to attach to the firewall on creation.+  , newFirewallLabels :: [Label]+    -- | Firewall rules.+  , newFirewallRules :: [FirewallRule]+    -- | List of servers to apply the firewall to on creation.+  , newFirewallServers :: [ServerID]+    -- | Label selectors to apply the firewall to matching resources.+  , newFirewallLabelSelectors :: [LabelSelectorAll]+    }++-- | IPv4 range containing every IP.+anyIPv4 :: IPv4Range+anyIPv4 = IPv4.range IPv4.any 0++-- | IPv6 range containing every IP.+anyIPv6 :: IPv6Range+anyIPv6 = IPv6.range IPv6.any 0++-- | Default firewall with two rules:+--+-- * Allow SSH on default port 22 from any address.+-- * Allow ICMP from any address.+--+defaultNewFirewall+  :: Text -- ^ Firewall name.+  -> NewFirewall+defaultNewFirewall name = NewFirewall+  { newFirewallName = name+  , newFirewallLabels = []+  , newFirewallRules =+      [ FirewallRule+          { firewallRuleDescription = Just "SSH"+          , firewallRuleDirection = TrafficIn+          , firewallRuleIPs = Left anyIPv4 :| [Right anyIPv6]+          , firewallRuleProtocol = FirewallRuleTCP $ singlePort 22+            }+      , FirewallRule+          { firewallRuleDescription = Just "ICMP"+          , firewallRuleDirection = TrafficIn+          , firewallRuleIPs = Left anyIPv4 :| [Right anyIPv6]+          , firewallRuleProtocol = FirewallRuleICMP+            }+        ]+  , newFirewallServers = []+  , newFirewallLabelSelectors = []+    }++-- | Result of creating a firewall with 'createFirewall'.+data CreatedFirewall = CreatedFirewall+  { -- | Actions associated with the firewall's creation.+    createdFirewallActions :: [Action]+    -- | The firewall just created.+  , createdFirewall :: Firewall+    } deriving Show++instance FromJSON CreatedFirewall where+  parseJSON = JSON.withObject "CreatedFirewall" $ \o ->+    CreatedFirewall <$> o .: "actions" <*> o .: "firewall"++-- | Get all firewalls in a project.+getFirewalls :: Token -> Maybe Int -> IO (WithMeta "firewalls" [Firewall])+getFirewalls = cloudQuery "GET" "/firewalls" noBody++-- | Get a single firewall.+getFirewall :: Token -> FirewallID -> IO Firewall+getFirewall token (FirewallID i) = withoutKey @"firewall" <$>+  cloudQuery "GET" ("/firewalls/" <> fromString (show i)) noBody token Nothing++-- | Create a firewall.+createFirewall :: Token -> NewFirewall -> IO CreatedFirewall+createFirewall token nfirewall =+  let servers = fmap (\i -> JSON.object [ "server" .= i ])+              $ newFirewallServers nfirewall+      selectors = fmap (\s -> JSON.object [ "selector" .= s ])+                $ newFirewallLabelSelectors nfirewall+      applyTo =+        fmap (\v -> JSON.object [ "type" .= ("server" :: Text)+                                , "server" .= v ]) servers+          ++ fmap (\v -> JSON.object [ "type" .= ("label_selector" :: Text)+                                     , "label_selector" .= v ]) selectors++      body = JSON.object+        [ "name" .= newFirewallName nfirewall+        , "labels" .= toLabelMap (newFirewallLabels nfirewall)+        , "rules" .= newFirewallRules nfirewall+        , "apply_to" .= applyTo+          ]+  in  cloudQuery "POST" "/firewalls" (Just body) token Nothing++-- | Delete a firewall.+deleteFirewall :: Token -> FirewallID -> IO ()+deleteFirewall token (FirewallID i) =+  cloudQuery "DELETE" ("/firewalls/" <> fromString (show i)) noBody token Nothing++-- | Update name and labels of a firewall.+updateFirewall+  :: Token+  -> FirewallID -- ^ Firewall to update.+  -> Text -- ^ New name for the firewall.+  -> [Label] -- ^ New labels for the firewall.+  -> IO Firewall+updateFirewall token (FirewallID i) name labels = withoutKey @"firewall" <$>+  let body = JSON.object+        [ "labels" .= toLabelMap labels+        , "name" .= name+          ]+  in  cloudQuery "PUT" ("/firewalls/" <> fromString (show i)) (Just body) token Nothing++-- | Apply a firewall to resources.+applyFirewall+  :: Token+  -> FirewallID -- ^ Firewall to apply.+  -> [ServerID] -- ^ Servers to apply the firewall to.+  -> [LabelSelectorAll] -- ^ Label selectors to apply.+  -> IO [Action]+applyFirewall token (FirewallID i) servers selectors = withoutKey @"actions" <$>+  let path = "/firewalls/" <> fromString (show i) <> "/actions/apply_to_resources"+      serverf server = JSON.object+        [ "type" .= ("server" :: Text)+        , "server" .= JSON.object [ "id" .= server ] ]+      selectorf selector = JSON.object+        [ "type" .= ("label_selector" :: Text)+        , "label_selector" .= JSON.object [ "selector" .= selector ] ]+      body = JSON.object+        [ "apply_to" .= (fmap serverf servers ++ fmap selectorf selectors)+          ]+  in  cloudQuery "POST" path (Just body) token Nothing++-- | Remove a firewall from the given resources. The firewall itself is not deleted.+--   For that, use 'deleteFirewall'.+removeFirewall+  :: Token+  -> FirewallID -- ^ Firewall to remove.+  -> [ServerID] -- ^ Servers to remove the firewall from.+  -> [LabelSelectorAll] -- ^ Label selectors to remove from the firewall.+  -> IO [Action]+removeFirewall token (FirewallID i) servers selectors = withoutKey @"actions" <$>+  let path = "/firewalls/" <> fromString (show i) <> "/actions/remove_from_resources"+      serverf server = JSON.object+        [ "type" .= ("server" :: Text)+        , "server" .= JSON.object [ "id" .= server ] ]+      selectorf selector = JSON.object+        [ "type" .= ("label_selector" :: Text)+        , "label_selector" .= JSON.object [ "selector" .= selector ] ]+      body = JSON.object+        [ "remove_from" .= (fmap serverf servers ++ fmap selectorf selectors)+          ]+  in  cloudQuery "POST" path (Just body) token Nothing+ ---------------------------------------------------------------------------------------------------- -- Floating IPs ----------------------------------------------------------------------------------------------------@@ -1316,7 +1751,8 @@   toJSON nserver = JSON.object $ mconcat     [ pure $ "automount" .= (newServerAutomount nserver && not (null $ newServerVolumes nserver))     , maybe mempty (pure . either ("datacenter".=) ("location".=)) $ newServerLocation nserver-    , pure $ "firewalls" .= newServerFirewalls nserver+    , pure $ "firewalls" .=+        fmap (\fwid -> JSON.object [ "firewall" .= fwid ]) (newServerFirewalls nserver)     , pure $ "image" .= newServerImage nserver     , pure $ "labels" .= toLabelMap (newServerLabels nserver)     , pure $ "name" .= newServerName nserver@@ -1495,6 +1931,8 @@   , serverTypeName :: Text   , serverPricing :: [PriceInLocation]   , serverStorageType :: StorageType+    -- | Outgoing traffic included (in bytes).+  , serverIncludedTraffic :: Int     } deriving Show  instance FromJSON ServerType where@@ -1510,6 +1948,7 @@     <*> o .: "name"     <*> o .: "prices"     <*> o .: "storage_type"+    <*> o .: "included_traffic"  -- | Get all server types. --@@ -1538,7 +1977,7 @@ instance FromJSON SSHKey where   parseJSON = JSON.withObject "SSHKey" $ \o -> SSHKey     <$> o .: "created"-    <*> o .: "fingerprint"+    <*> (fingerprint <$> o .: "fingerprint")     <*> o .: "id"     <*> o .: "labels"     <*> o .: "name"
src/Hetzner/Cloud/Fingerprint.hs view
@@ -1,9 +1,11 @@-{-# OPTIONS_GHC -Wno-orphans #-}  -- | Parsers for the 'Fingerprint' type.+--+--   This is an internal module that's not needed for the+--   normal use of the library. module Hetzner.Cloud.Fingerprint (     Fingerprint-  , fingerprintParser+  , FingerprintText (..)   ) where  -- base@@ -27,6 +29,7 @@  type Parser = Parser.Parsec Void Text +-- | Text-based 'Fingerprint' parser. fingerprintParser :: Parser Fingerprint fingerprintParser = do   bs0 <- liftA2 (:) Parser.hexadecimal $@@ -38,7 +41,10 @@       combineBytes = foldl' f 0 . zip [0..]   pure $ Fingerprint (combineBytes bs0) (combineBytes bs1) -instance FromJSON Fingerprint where+-- | A wrapper of 'Fingerprint' with a custom 'FromJSON' instance.+newtype FingerprintText = FingerprintText { fingerprint :: Fingerprint }++instance FromJSON FingerprintText where   parseJSON = JSON.withText "Fingerprint" $ \t ->-    either (fail . Parser.errorBundlePretty) pure $+    either (fail . Parser.errorBundlePretty) (pure . FingerprintText) $       Parser.runParser (fingerprintParser <* Parser.eof) "JSON input" t