Add two more Haskell examples (with their note files)
This commit is contained in:
parent
6a0c04b9c5
commit
6787a9cc4f
25
26-haskell-quickcheck/README.md
Normal file
25
26-haskell-quickcheck/README.md
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
# 26-haskell-quickcheck
|
||||||
|
|
||||||
|
This example shows intermediate Haskell property testing with QuickCheck.
|
||||||
|
|
||||||
|
It includes:
|
||||||
|
|
||||||
|
- a non-trivial window normalization function,
|
||||||
|
- a CLI that renders merged maintenance windows,
|
||||||
|
- QuickCheck properties for normalization shape, idempotence, and coverage, and
|
||||||
|
- a test suite run by `nix flake check`.
|
||||||
|
|
||||||
|
Useful commands:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
nix develop
|
||||||
|
cabal run
|
||||||
|
cabal run -- 0-10 8-14 20-24 24-30
|
||||||
|
cabal test
|
||||||
|
|
||||||
|
nix build
|
||||||
|
./result/bin/mini-windows 0-10 8-14 20-24 24-30
|
||||||
|
|
||||||
|
nix run . -- 0-10 8-14 20-24 24-30
|
||||||
|
nix flake check
|
||||||
|
```
|
||||||
21
26-haskell-quickcheck/app/Main.hs
Normal file
21
26-haskell-quickcheck/app/Main.hs
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
module Main where
|
||||||
|
|
||||||
|
import MiniWindows.Normalize
|
||||||
|
( normalizeWindows
|
||||||
|
, parseWindow
|
||||||
|
, renderWindows
|
||||||
|
)
|
||||||
|
import System.Environment (getArgs)
|
||||||
|
import System.Exit (die)
|
||||||
|
|
||||||
|
main :: IO ()
|
||||||
|
main = do
|
||||||
|
args <- getArgs
|
||||||
|
let inputArgs =
|
||||||
|
case args of
|
||||||
|
[] -> ["0-10", "8-14", "20-24", "24-30"]
|
||||||
|
_ -> args
|
||||||
|
|
||||||
|
case traverse parseWindow inputArgs of
|
||||||
|
Left err -> die err
|
||||||
|
Right windows -> putStrLn (renderWindows (normalizeWindows windows))
|
||||||
27
26-haskell-quickcheck/flake.lock
generated
Normal file
27
26-haskell-quickcheck/flake.lock
generated
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"nodes": {
|
||||||
|
"nixpkgs": {
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1776548001,
|
||||||
|
"narHash": "sha256-ZSK0NL4a1BwVbbTBoSnWgbJy9HeZFXLYQizjb2DPF24=",
|
||||||
|
"owner": "NixOS",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"rev": "b12141ef619e0a9c1c84dc8c684040326f27cdcc",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "NixOS",
|
||||||
|
"ref": "nixos-unstable",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"root": {
|
||||||
|
"inputs": {
|
||||||
|
"nixpkgs": "nixpkgs"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"root": "root",
|
||||||
|
"version": 7
|
||||||
|
}
|
||||||
38
26-haskell-quickcheck/flake.nix
Normal file
38
26-haskell-quickcheck/flake.nix
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
{
|
||||||
|
# Builds a Haskell project that normalizes maintenance windows and checks
|
||||||
|
# the normalization logic with QuickCheck properties.
|
||||||
|
description = "A Haskell project for QuickCheck properties";
|
||||||
|
|
||||||
|
inputs = {
|
||||||
|
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||||
|
};
|
||||||
|
|
||||||
|
outputs =
|
||||||
|
{ self, nixpkgs, ... }:
|
||||||
|
let
|
||||||
|
system = "x86_64-linux";
|
||||||
|
pkgs = import nixpkgs { inherit system; };
|
||||||
|
inherit (pkgs) haskellPackages;
|
||||||
|
project = haskellPackages.callCabal2nix "mini-windows" ./. { };
|
||||||
|
checkedProject = pkgs.haskell.lib.doCheck project;
|
||||||
|
in
|
||||||
|
{
|
||||||
|
packages.${system}.default = project;
|
||||||
|
|
||||||
|
apps.${system}.default = {
|
||||||
|
type = "app";
|
||||||
|
program = "${self.packages.${system}.default}/bin/mini-windows";
|
||||||
|
meta.description = "Run the QuickCheck maintenance window example.";
|
||||||
|
};
|
||||||
|
|
||||||
|
devShells.${system}.default = pkgs.mkShell {
|
||||||
|
packages = [
|
||||||
|
haskellPackages.ghc
|
||||||
|
pkgs.cabal-install
|
||||||
|
pkgs.haskell-language-server
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
checks.${system}.test-suite = checkedProject;
|
||||||
|
};
|
||||||
|
}
|
||||||
29
26-haskell-quickcheck/mini-windows.cabal
Normal file
29
26-haskell-quickcheck/mini-windows.cabal
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
cabal-version: 2.4
|
||||||
|
name: mini-windows
|
||||||
|
version: 0.1.0.0
|
||||||
|
build-type: Simple
|
||||||
|
|
||||||
|
library
|
||||||
|
exposed-modules: MiniWindows.Normalize
|
||||||
|
hs-source-dirs: src
|
||||||
|
build-depends:
|
||||||
|
base >=4.14 && <5
|
||||||
|
default-language: Haskell2010
|
||||||
|
|
||||||
|
executable mini-windows
|
||||||
|
main-is: Main.hs
|
||||||
|
hs-source-dirs: app
|
||||||
|
build-depends:
|
||||||
|
base >=4.14 && <5,
|
||||||
|
mini-windows
|
||||||
|
default-language: Haskell2010
|
||||||
|
|
||||||
|
test-suite mini-windows-test
|
||||||
|
type: exitcode-stdio-1.0
|
||||||
|
main-is: Main.hs
|
||||||
|
hs-source-dirs: test
|
||||||
|
build-depends:
|
||||||
|
base >=4.14 && <5,
|
||||||
|
mini-windows,
|
||||||
|
QuickCheck
|
||||||
|
default-language: Haskell2010
|
||||||
62
26-haskell-quickcheck/src/MiniWindows/Normalize.hs
Normal file
62
26-haskell-quickcheck/src/MiniWindows/Normalize.hs
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
module MiniWindows.Normalize where
|
||||||
|
|
||||||
|
import Data.List (sortOn)
|
||||||
|
|
||||||
|
data Window = Window
|
||||||
|
{ windowStart :: Int
|
||||||
|
, windowEnd :: Int
|
||||||
|
}
|
||||||
|
deriving (Eq, Show)
|
||||||
|
|
||||||
|
mkWindow :: Int -> Int -> Maybe Window
|
||||||
|
mkWindow startMinute endMinute
|
||||||
|
| startMinute < 0 = Nothing
|
||||||
|
| endMinute <= startMinute = Nothing
|
||||||
|
| otherwise = Just (Window startMinute endMinute)
|
||||||
|
|
||||||
|
parseWindow :: String -> Either String Window
|
||||||
|
parseWindow rawWindow =
|
||||||
|
case break (== '-') rawWindow of
|
||||||
|
(rawStart, '-' : rawEnd) ->
|
||||||
|
case (reads rawStart, reads rawEnd) of
|
||||||
|
([(startMinute, "")], [(endMinute, "")]) ->
|
||||||
|
case mkWindow startMinute endMinute of
|
||||||
|
Just parsedWindow -> Right parsedWindow
|
||||||
|
Nothing -> Left ("invalid window bounds: " ++ rawWindow)
|
||||||
|
_ -> Left ("invalid window bounds: " ++ rawWindow)
|
||||||
|
_ -> Left ("expected <start>-<end>, got: " ++ rawWindow)
|
||||||
|
|
||||||
|
normalizeWindows :: [Window] -> [Window]
|
||||||
|
normalizeWindows = foldr mergeWindow [] . sortOn windowStart
|
||||||
|
|
||||||
|
renderWindows :: [Window] -> String
|
||||||
|
renderWindows [] = "no maintenance windows"
|
||||||
|
renderWindows windows = unwords (map renderWindow windows)
|
||||||
|
|
||||||
|
renderWindow :: Window -> String
|
||||||
|
renderWindow window = show (windowStart window) ++ "-" ++ show (windowEnd window)
|
||||||
|
|
||||||
|
coversMinute :: Int -> [Window] -> Bool
|
||||||
|
coversMinute minute = any covers
|
||||||
|
where
|
||||||
|
covers window = windowStart window <= minute && minute < windowEnd window
|
||||||
|
|
||||||
|
isNormalized :: [Window] -> Bool
|
||||||
|
isNormalized [] = True
|
||||||
|
isNormalized [_] = True
|
||||||
|
isNormalized (firstWindow : secondWindow : remainingWindows) =
|
||||||
|
windowStart firstWindow < windowEnd firstWindow
|
||||||
|
&& windowEnd firstWindow < windowStart secondWindow
|
||||||
|
&& isNormalized (secondWindow : remainingWindows)
|
||||||
|
|
||||||
|
mergeWindow :: Window -> [Window] -> [Window]
|
||||||
|
mergeWindow window [] = [window]
|
||||||
|
mergeWindow window (nextWindow : remainingWindows)
|
||||||
|
| windowEnd window >= windowStart nextWindow =
|
||||||
|
mergeWindow
|
||||||
|
Window
|
||||||
|
{ windowStart = min (windowStart window) (windowStart nextWindow)
|
||||||
|
, windowEnd = max (windowEnd window) (windowEnd nextWindow)
|
||||||
|
}
|
||||||
|
remainingWindows
|
||||||
|
| otherwise = window : nextWindow : remainingWindows
|
||||||
64
26-haskell-quickcheck/test/Main.hs
Normal file
64
26-haskell-quickcheck/test/Main.hs
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
module Main where
|
||||||
|
|
||||||
|
import MiniWindows.Normalize
|
||||||
|
( Window (Window)
|
||||||
|
, coversMinute
|
||||||
|
, isNormalized
|
||||||
|
, normalizeWindows
|
||||||
|
)
|
||||||
|
import System.Exit (die)
|
||||||
|
import Test.QuickCheck
|
||||||
|
( Gen
|
||||||
|
, Property
|
||||||
|
, chooseInt
|
||||||
|
, forAll
|
||||||
|
, isSuccess
|
||||||
|
, listOf
|
||||||
|
, quickCheckResult
|
||||||
|
)
|
||||||
|
|
||||||
|
newtype WindowList = WindowList [Window]
|
||||||
|
deriving (Show)
|
||||||
|
|
||||||
|
genWindow :: Gen Window
|
||||||
|
genWindow = do
|
||||||
|
startMinute <- chooseInt (0, 59)
|
||||||
|
endMinute <- chooseInt (startMinute + 1, 60)
|
||||||
|
pure (Window startMinute endMinute)
|
||||||
|
|
||||||
|
genWindowList :: Gen WindowList
|
||||||
|
genWindowList = WindowList <$> listOf genWindow
|
||||||
|
|
||||||
|
prop_exampleMerge :: Bool
|
||||||
|
prop_exampleMerge =
|
||||||
|
normalizeWindows [Window 0 10, Window 8 14, Window 20 24, Window 24 30]
|
||||||
|
== [Window 0 14, Window 20 30]
|
||||||
|
|
||||||
|
prop_idempotent :: Property
|
||||||
|
prop_idempotent =
|
||||||
|
forAll genWindowList $ \(WindowList windows) ->
|
||||||
|
normalizeWindows (normalizeWindows windows) == normalizeWindows windows
|
||||||
|
|
||||||
|
prop_resultIsNormalized :: Property
|
||||||
|
prop_resultIsNormalized =
|
||||||
|
forAll genWindowList $ \(WindowList windows) ->
|
||||||
|
isNormalized (normalizeWindows windows)
|
||||||
|
|
||||||
|
prop_preservesCoverage :: Property
|
||||||
|
prop_preservesCoverage =
|
||||||
|
forAll genWindowList $ \(WindowList windows) ->
|
||||||
|
all
|
||||||
|
(\minute -> coversMinute minute windows == coversMinute minute (normalizeWindows windows))
|
||||||
|
[0 .. 60]
|
||||||
|
|
||||||
|
main :: IO ()
|
||||||
|
main = do
|
||||||
|
exampleResult <- quickCheckResult prop_exampleMerge
|
||||||
|
idempotentResult <- quickCheckResult prop_idempotent
|
||||||
|
normalizedResult <- quickCheckResult prop_resultIsNormalized
|
||||||
|
coverageResult <- quickCheckResult prop_preservesCoverage
|
||||||
|
|
||||||
|
if all isSuccess [exampleResult, idempotentResult, normalizedResult, coverageResult] then
|
||||||
|
putStrLn "test passed"
|
||||||
|
else
|
||||||
|
die "unexpected QuickCheck result"
|
||||||
25
27-haskell-aeson-roundtrip/README.md
Normal file
25
27-haskell-aeson-roundtrip/README.md
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
# 27-haskell-aeson-roundtrip
|
||||||
|
|
||||||
|
This example shows intermediate Haskell JSON work with explicit Aeson round trips.
|
||||||
|
|
||||||
|
It includes:
|
||||||
|
|
||||||
|
- custom `ToJSON` and `FromJSON` instances for domain types,
|
||||||
|
- a nested rollout strategy encoded with a stable JSON shape,
|
||||||
|
- a CLI that prints JSON and decodes it back into a summary, and
|
||||||
|
- a test suite run by `nix flake check`.
|
||||||
|
|
||||||
|
Useful commands:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
nix develop
|
||||||
|
cabal run
|
||||||
|
cabal run -- api production 3 platform,security canary:10
|
||||||
|
cabal test
|
||||||
|
|
||||||
|
nix build
|
||||||
|
./result/bin/mini-manifest api production 3 platform,security canary:10
|
||||||
|
|
||||||
|
nix run . -- api production 3 platform,security canary:10
|
||||||
|
nix flake check
|
||||||
|
```
|
||||||
28
27-haskell-aeson-roundtrip/app/Main.hs
Normal file
28
27-haskell-aeson-roundtrip/app/Main.hs
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
module Main where
|
||||||
|
|
||||||
|
import qualified Data.ByteString.Lazy.Char8 as ByteString
|
||||||
|
import MiniManifest.Json
|
||||||
|
( decodeManifest
|
||||||
|
, encodeManifest
|
||||||
|
, parseManifestArgs
|
||||||
|
, renderManifest
|
||||||
|
)
|
||||||
|
import System.Environment (getArgs)
|
||||||
|
import System.Exit (die)
|
||||||
|
|
||||||
|
main :: IO ()
|
||||||
|
main = do
|
||||||
|
args <- getArgs
|
||||||
|
let inputArgs =
|
||||||
|
case args of
|
||||||
|
[] -> ["api", "production", "3", "platform,security", "canary:10"]
|
||||||
|
_ -> args
|
||||||
|
|
||||||
|
case parseManifestArgs inputArgs of
|
||||||
|
Left err -> die err
|
||||||
|
Right manifest -> do
|
||||||
|
let encodedManifest = encodeManifest manifest
|
||||||
|
ByteString.putStrLn encodedManifest
|
||||||
|
case decodeManifest encodedManifest of
|
||||||
|
Left err -> die err
|
||||||
|
Right decodedManifest -> putStrLn (renderManifest decodedManifest)
|
||||||
27
27-haskell-aeson-roundtrip/flake.lock
generated
Normal file
27
27-haskell-aeson-roundtrip/flake.lock
generated
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"nodes": {
|
||||||
|
"nixpkgs": {
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1776548001,
|
||||||
|
"narHash": "sha256-ZSK0NL4a1BwVbbTBoSnWgbJy9HeZFXLYQizjb2DPF24=",
|
||||||
|
"owner": "NixOS",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"rev": "b12141ef619e0a9c1c84dc8c684040326f27cdcc",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "NixOS",
|
||||||
|
"ref": "nixos-unstable",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"root": {
|
||||||
|
"inputs": {
|
||||||
|
"nixpkgs": "nixpkgs"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"root": "root",
|
||||||
|
"version": 7
|
||||||
|
}
|
||||||
38
27-haskell-aeson-roundtrip/flake.nix
Normal file
38
27-haskell-aeson-roundtrip/flake.nix
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
{
|
||||||
|
# Builds a Haskell project that encodes and decodes deployment manifests
|
||||||
|
# with explicit Aeson instances and round-trip checks.
|
||||||
|
description = "A Haskell project for Aeson round trips";
|
||||||
|
|
||||||
|
inputs = {
|
||||||
|
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||||
|
};
|
||||||
|
|
||||||
|
outputs =
|
||||||
|
{ self, nixpkgs, ... }:
|
||||||
|
let
|
||||||
|
system = "x86_64-linux";
|
||||||
|
pkgs = import nixpkgs { inherit system; };
|
||||||
|
inherit (pkgs) haskellPackages;
|
||||||
|
project = haskellPackages.callCabal2nix "mini-manifest" ./. { };
|
||||||
|
checkedProject = pkgs.haskell.lib.doCheck project;
|
||||||
|
in
|
||||||
|
{
|
||||||
|
packages.${system}.default = project;
|
||||||
|
|
||||||
|
apps.${system}.default = {
|
||||||
|
type = "app";
|
||||||
|
program = "${self.packages.${system}.default}/bin/mini-manifest";
|
||||||
|
meta.description = "Run the Aeson round-trip manifest example.";
|
||||||
|
};
|
||||||
|
|
||||||
|
devShells.${system}.default = pkgs.mkShell {
|
||||||
|
packages = [
|
||||||
|
haskellPackages.ghc
|
||||||
|
pkgs.cabal-install
|
||||||
|
pkgs.haskell-language-server
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
checks.${system}.test-suite = checkedProject;
|
||||||
|
};
|
||||||
|
}
|
||||||
32
27-haskell-aeson-roundtrip/mini-manifest.cabal
Normal file
32
27-haskell-aeson-roundtrip/mini-manifest.cabal
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
cabal-version: 2.4
|
||||||
|
name: mini-manifest
|
||||||
|
version: 0.1.0.0
|
||||||
|
build-type: Simple
|
||||||
|
|
||||||
|
library
|
||||||
|
exposed-modules: MiniManifest.Json
|
||||||
|
hs-source-dirs: src
|
||||||
|
build-depends:
|
||||||
|
aeson,
|
||||||
|
base >=4.14 && <5,
|
||||||
|
bytestring,
|
||||||
|
text
|
||||||
|
default-language: Haskell2010
|
||||||
|
|
||||||
|
executable mini-manifest
|
||||||
|
main-is: Main.hs
|
||||||
|
hs-source-dirs: app
|
||||||
|
build-depends:
|
||||||
|
base >=4.14 && <5,
|
||||||
|
bytestring,
|
||||||
|
mini-manifest
|
||||||
|
default-language: Haskell2010
|
||||||
|
|
||||||
|
test-suite mini-manifest-test
|
||||||
|
type: exitcode-stdio-1.0
|
||||||
|
main-is: Main.hs
|
||||||
|
hs-source-dirs: test
|
||||||
|
build-depends:
|
||||||
|
base >=4.14 && <5,
|
||||||
|
mini-manifest
|
||||||
|
default-language: Haskell2010
|
||||||
158
27-haskell-aeson-roundtrip/src/MiniManifest/Json.hs
Normal file
158
27-haskell-aeson-roundtrip/src/MiniManifest/Json.hs
Normal file
@ -0,0 +1,158 @@
|
|||||||
|
{-# LANGUAGE OverloadedStrings #-}
|
||||||
|
|
||||||
|
module MiniManifest.Json where
|
||||||
|
|
||||||
|
import Data.Aeson
|
||||||
|
( FromJSON (parseJSON)
|
||||||
|
, ToJSON (toJSON)
|
||||||
|
, Value (String)
|
||||||
|
, encode
|
||||||
|
, eitherDecode
|
||||||
|
, object
|
||||||
|
, withObject
|
||||||
|
, withText
|
||||||
|
, (.:)
|
||||||
|
, (.=)
|
||||||
|
)
|
||||||
|
import qualified Data.ByteString.Lazy as ByteString
|
||||||
|
import Data.List (intercalate)
|
||||||
|
import Data.Text (Text)
|
||||||
|
import qualified Data.Text as Text
|
||||||
|
|
||||||
|
data Environment
|
||||||
|
= Staging
|
||||||
|
| Production
|
||||||
|
deriving (Eq, Show)
|
||||||
|
|
||||||
|
data RolloutStrategy
|
||||||
|
= Full
|
||||||
|
| Canary Int
|
||||||
|
deriving (Eq, Show)
|
||||||
|
|
||||||
|
data DeploymentManifest = DeploymentManifest
|
||||||
|
{ manifestService :: Text
|
||||||
|
, manifestEnvironment :: Environment
|
||||||
|
, manifestReplicas :: Int
|
||||||
|
, manifestOwners :: [Text]
|
||||||
|
, manifestStrategy :: RolloutStrategy
|
||||||
|
}
|
||||||
|
deriving (Eq, Show)
|
||||||
|
|
||||||
|
instance ToJSON Environment where
|
||||||
|
toJSON Staging = String "staging"
|
||||||
|
toJSON Production = String "production"
|
||||||
|
|
||||||
|
instance FromJSON Environment where
|
||||||
|
parseJSON =
|
||||||
|
withText "Environment" $ \value ->
|
||||||
|
case value of
|
||||||
|
"staging" -> pure Staging
|
||||||
|
"production" -> pure Production
|
||||||
|
_ -> fail ("unknown environment: " ++ Text.unpack value)
|
||||||
|
|
||||||
|
instance ToJSON RolloutStrategy where
|
||||||
|
toJSON Full = object ["type" .= String "full"]
|
||||||
|
toJSON (Canary percent) = object ["type" .= String "canary", "percentage" .= percent]
|
||||||
|
|
||||||
|
instance FromJSON RolloutStrategy where
|
||||||
|
parseJSON =
|
||||||
|
withObject "RolloutStrategy" $ \value -> do
|
||||||
|
strategyType <- value .: "type"
|
||||||
|
case (strategyType :: Text) of
|
||||||
|
"full" -> pure Full
|
||||||
|
"canary" -> Canary <$> value .: "percentage"
|
||||||
|
_ -> fail ("unknown strategy type: " ++ Text.unpack strategyType)
|
||||||
|
|
||||||
|
instance ToJSON DeploymentManifest where
|
||||||
|
toJSON manifest =
|
||||||
|
object
|
||||||
|
[ "service" .= manifestService manifest
|
||||||
|
, "environment" .= manifestEnvironment manifest
|
||||||
|
, "replicas" .= manifestReplicas manifest
|
||||||
|
, "owners" .= manifestOwners manifest
|
||||||
|
, "strategy" .= manifestStrategy manifest
|
||||||
|
]
|
||||||
|
|
||||||
|
instance FromJSON DeploymentManifest where
|
||||||
|
parseJSON =
|
||||||
|
withObject "DeploymentManifest" $ \value ->
|
||||||
|
DeploymentManifest
|
||||||
|
<$> value .: "service"
|
||||||
|
<*> value .: "environment"
|
||||||
|
<*> value .: "replicas"
|
||||||
|
<*> value .: "owners"
|
||||||
|
<*> value .: "strategy"
|
||||||
|
|
||||||
|
parseManifestArgs :: [String] -> Either String DeploymentManifest
|
||||||
|
parseManifestArgs [serviceName, environmentName, rawReplicas, rawOwners, rawStrategy] = do
|
||||||
|
environment <- parseEnvironment environmentName
|
||||||
|
replicas <- parseReplicas rawReplicas
|
||||||
|
strategy <- parseStrategy rawStrategy
|
||||||
|
pure
|
||||||
|
DeploymentManifest
|
||||||
|
{ manifestService = Text.pack serviceName
|
||||||
|
, manifestEnvironment = environment
|
||||||
|
, manifestReplicas = replicas
|
||||||
|
, manifestOwners = map Text.pack (splitOn ',' rawOwners)
|
||||||
|
, manifestStrategy = strategy
|
||||||
|
}
|
||||||
|
parseManifestArgs _ =
|
||||||
|
Left "expected either no arguments or: <service> <environment> <replicas> <owners> <full|canary:percent>"
|
||||||
|
|
||||||
|
encodeManifest :: DeploymentManifest -> ByteString.ByteString
|
||||||
|
encodeManifest = encode
|
||||||
|
|
||||||
|
decodeManifest :: ByteString.ByteString -> Either String DeploymentManifest
|
||||||
|
decodeManifest = eitherDecode
|
||||||
|
|
||||||
|
renderManifest :: DeploymentManifest -> String
|
||||||
|
renderManifest manifest =
|
||||||
|
intercalate
|
||||||
|
", "
|
||||||
|
[ "service " ++ Text.unpack (manifestService manifest)
|
||||||
|
, "env " ++ renderEnvironment (manifestEnvironment manifest)
|
||||||
|
, "replicas " ++ show (manifestReplicas manifest)
|
||||||
|
, "owners " ++ intercalate "/" (map Text.unpack (manifestOwners manifest))
|
||||||
|
, "strategy " ++ renderStrategy (manifestStrategy manifest)
|
||||||
|
]
|
||||||
|
|
||||||
|
parseEnvironment :: String -> Either String Environment
|
||||||
|
parseEnvironment "staging" = Right Staging
|
||||||
|
parseEnvironment "production" = Right Production
|
||||||
|
parseEnvironment other = Left ("unknown environment: " ++ other)
|
||||||
|
|
||||||
|
parseReplicas :: String -> Either String Int
|
||||||
|
parseReplicas rawReplicas =
|
||||||
|
case reads rawReplicas of
|
||||||
|
[(parsedReplicas, "")]
|
||||||
|
| parsedReplicas > 0 -> Right parsedReplicas
|
||||||
|
| otherwise -> Left "replicas must be greater than zero"
|
||||||
|
_ -> Left ("invalid replica count: " ++ rawReplicas)
|
||||||
|
|
||||||
|
parseStrategy :: String -> Either String RolloutStrategy
|
||||||
|
parseStrategy "full" = Right Full
|
||||||
|
parseStrategy rawStrategy =
|
||||||
|
case break (== ':') rawStrategy of
|
||||||
|
("canary", ':' : rawPercent) ->
|
||||||
|
case reads rawPercent of
|
||||||
|
[(parsedPercent, "")]
|
||||||
|
| parsedPercent >= 1 && parsedPercent <= 50 -> Right (Canary parsedPercent)
|
||||||
|
| otherwise -> Left "canary percentage must be between 1 and 50"
|
||||||
|
_ -> Left ("invalid canary percentage: " ++ rawPercent)
|
||||||
|
_ -> Left ("unknown strategy: " ++ rawStrategy)
|
||||||
|
|
||||||
|
renderEnvironment :: Environment -> String
|
||||||
|
renderEnvironment Staging = "staging"
|
||||||
|
renderEnvironment Production = "production"
|
||||||
|
|
||||||
|
renderStrategy :: RolloutStrategy -> String
|
||||||
|
renderStrategy Full = "full"
|
||||||
|
renderStrategy (Canary percent) = "canary " ++ show percent ++ "%"
|
||||||
|
|
||||||
|
splitOn :: Char -> String -> [String]
|
||||||
|
splitOn separator = go []
|
||||||
|
where
|
||||||
|
go current [] = [reverse current]
|
||||||
|
go current (nextChar : remainingChars)
|
||||||
|
| nextChar == separator = reverse current : go [] remainingChars
|
||||||
|
| otherwise = go (nextChar : current) remainingChars
|
||||||
35
27-haskell-aeson-roundtrip/test/Main.hs
Normal file
35
27-haskell-aeson-roundtrip/test/Main.hs
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
{-# LANGUAGE OverloadedStrings #-}
|
||||||
|
|
||||||
|
module Main where
|
||||||
|
|
||||||
|
import MiniManifest.Json
|
||||||
|
( DeploymentManifest (..)
|
||||||
|
, Environment (Production)
|
||||||
|
, RolloutStrategy (Canary)
|
||||||
|
, decodeManifest
|
||||||
|
, encodeManifest
|
||||||
|
, renderManifest
|
||||||
|
)
|
||||||
|
import System.Exit (die)
|
||||||
|
|
||||||
|
sampleManifest :: DeploymentManifest
|
||||||
|
sampleManifest =
|
||||||
|
DeploymentManifest
|
||||||
|
{ manifestService = "api"
|
||||||
|
, manifestEnvironment = Production
|
||||||
|
, manifestReplicas = 3
|
||||||
|
, manifestOwners = ["platform", "security"]
|
||||||
|
, manifestStrategy = Canary 10
|
||||||
|
}
|
||||||
|
|
||||||
|
main :: IO ()
|
||||||
|
main =
|
||||||
|
case
|
||||||
|
( decodeManifest (encodeManifest sampleManifest)
|
||||||
|
, decodeManifest "{\"service\":\"api\"}"
|
||||||
|
) of
|
||||||
|
( Right decodedManifest
|
||||||
|
, Left _
|
||||||
|
) | renderManifest decodedManifest == "service api, env production, replicas 3, owners platform/security, strategy canary 10%" ->
|
||||||
|
putStrLn "test passed"
|
||||||
|
_ -> die "unexpected JSON round-trip result"
|
||||||
51
notes/029-haskell-quickcheck.md
Normal file
51
notes/029-haskell-quickcheck.md
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
# Haskell QuickCheck
|
||||||
|
|
||||||
|
This note covers `26-haskell-quickcheck/`, which normalizes overlapping maintenance windows and checks the implementation with QuickCheck
|
||||||
|
properties.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Why Properties Help Here
|
||||||
|
|
||||||
|
The function under test is not a single arithmetic helper. It sorts, merges, and preserves the covered time range of several windows.
|
||||||
|
|
||||||
|
That kind of behavior is a strong fit for property testing, because you care about broad rules:
|
||||||
|
|
||||||
|
- the result should be normalized,
|
||||||
|
- normalizing twice should not change the answer, and
|
||||||
|
- the normalized result should cover exactly the same minutes as the input.
|
||||||
|
|
||||||
|
Those are better teaching examples for QuickCheck than a one-line `reverse . reverse` property.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. What the Generator Controls
|
||||||
|
|
||||||
|
The test suite generates windows within a bounded minute range.
|
||||||
|
|
||||||
|
That keeps the coverage property finite, because the test can compare membership across `0..60` directly.
|
||||||
|
|
||||||
|
The important point is not the numeric range itself. It is the workflow:
|
||||||
|
|
||||||
|
1. generate realistic structured input,
|
||||||
|
2. state invariant-like properties, and
|
||||||
|
3. let QuickCheck search for counterexamples.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Commands to Try
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd 26-haskell-quickcheck
|
||||||
|
|
||||||
|
nix develop
|
||||||
|
cabal run
|
||||||
|
cabal run -- 0-10 8-14 20-24 24-30
|
||||||
|
cabal test
|
||||||
|
|
||||||
|
nix build
|
||||||
|
./result/bin/mini-windows 0-10 8-14 20-24 24-30
|
||||||
|
|
||||||
|
nix run . -- 0-10 8-14 20-24 24-30
|
||||||
|
nix flake check
|
||||||
|
```
|
||||||
58
notes/030-haskell-aeson-roundtrip.md
Normal file
58
notes/030-haskell-aeson-roundtrip.md
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
# Haskell Aeson Round Trips
|
||||||
|
|
||||||
|
This note covers `27-haskell-aeson-roundtrip/`, which defines explicit JSON instances for deployment manifests and checks that encoding followed by
|
||||||
|
decoding preserves the manifest value.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Why the Instances Are Explicit
|
||||||
|
|
||||||
|
The example could have used generic deriving, but that would hide the JSON shape.
|
||||||
|
|
||||||
|
Instead, it defines instances by hand for:
|
||||||
|
|
||||||
|
- `Environment`,
|
||||||
|
- `RolloutStrategy`, and
|
||||||
|
- `DeploymentManifest`.
|
||||||
|
|
||||||
|
That makes the wire format obvious, especially for the nested strategy object.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. What the Round Trip Proves
|
||||||
|
|
||||||
|
The main test checks this flow:
|
||||||
|
|
||||||
|
1. start with a manifest value,
|
||||||
|
2. encode it to JSON,
|
||||||
|
3. decode the JSON back, and
|
||||||
|
4. compare the result with the original value.
|
||||||
|
|
||||||
|
That does not prove every possible JSON input is valid, but it does prove that the encoder and decoder agree on the example's own format.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Why the Strategy Shape Is Interesting
|
||||||
|
|
||||||
|
`RolloutStrategy` is not encoded as a bare string. It becomes an object with a `type` field and, for canary rollouts, a `percentage` field.
|
||||||
|
|
||||||
|
That is a more realistic format for APIs because it leaves room for strategy-specific data while keeping a stable top-level manifest shape.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Commands to Try
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd 27-haskell-aeson-roundtrip
|
||||||
|
|
||||||
|
nix develop
|
||||||
|
cabal run
|
||||||
|
cabal run -- api production 3 platform,security canary:10
|
||||||
|
cabal test
|
||||||
|
|
||||||
|
nix build
|
||||||
|
./result/bin/mini-manifest api production 3 platform,security canary:10
|
||||||
|
|
||||||
|
nix run . -- api production 3 platform,security canary:10
|
||||||
|
nix flake check
|
||||||
|
```
|
||||||
Loading…
x
Reference in New Issue
Block a user