33 Commits
Author SHA1 Message Date
Paul Brinkmeier 2dacd4f747 Use /tmp for running dbmate
deliver / deliver (push) Successful in 2m6s
2025-10-07 16:48:53 +02:00
paul 94ede9a168 Use SIGINT as container StopSignal
deliver / deliver (push) Failing after 20s
2025-10-07 13:47:12 +02:00
Paul Brinkmeier c38574e005 Add exec in entrypoint
deliver / deliver (push) Successful in 1m40s
2025-10-07 13:26:46 +02:00
Paul Brinkmeier 700d53fbb0 Update todo list 2025-10-07 13:09:24 +02:00
Paul Brinkmeier c5c11ed181 Don't buffer stdout
deliver / deliver (push) Successful in 1m36s
2025-10-07 12:53:14 +02:00
paul 36c9b880a7 Merge pull request 'Add entrypoint script and cacerts to image' (#2) from image-entry into main
Reviewed-on: #2
2025-10-07 12:50:53 +02:00
paul c25ead672b Add entrypoint script and cacerts to image
deliver / deliver (push) Successful in 2m0s
build / build (pull_request) Successful in 16s
2025-10-07 12:43:48 +02:00
Paul Brinkmeier 4a6e3b2454 Reformat image flake output 2025-10-07 10:28:30 +02:00
paul b739294638 Merge pull request 'CI' (#1) from ci into main
Reviewed-on: #1
2025-10-07 01:17:42 +02:00
Paul Brinkmeier 27f5b8e23c Fix reading image tag
build / build (pull_request) Successful in 1m14s
deliver / deliver (push) Successful in 37s
2025-10-07 01:11:18 +02:00
Paul Brinkmeier 48eb27e218 Add docker image CI workflow
build / build (pull_request) Successful in 1m17s
deliver / deliver (push) Failing after 48s
2025-10-07 01:07:22 +02:00
Paul Brinkmeier 94554ad6e4 Bump opium
build / build (pull_request) Successful in 2m29s
2025-10-07 00:49:51 +02:00
Paul Brinkmeier 1ccde9e5fc Add Gitea Actions config
build / build (pull_request) Successful in 5m40s
2025-10-07 00:26:30 +02:00
Paul Brinkmeier 999c9e8c9e Ignore .gitea in flake.nix 2025-10-07 00:20:34 +02:00
Paul Brinkmeier eff74cecb4 dbmate changes 2025-10-06 10:22:28 +02:00
Paul Brinkmeier e9934276d5 Log service time 2025-10-06 08:52:39 +02:00
Paul Brinkmeier 72ffa08e18 Collect all errors in envy 2025-10-06 08:51:22 +02:00
Paul Brinkmeier 2b85c0fa93 dbmate changes 2025-10-06 08:49:52 +02:00
Paul Brinkmeier 05943c902e Remove get100YearsAgo 2025-09-22 23:54:54 +02:00
Paul Brinkmeier 0bf734730b Move Accept HTML in Yore.Servant 2025-09-22 19:04:51 +02:00
Paul Brinkmeier f2a136d27c Implement HasServer instead of using Raw 2025-09-22 18:04:13 +02:00
Paul Brinkmeier 85bbc8516a Make doIndex a loop 2025-09-22 16:07:42 +02:00
Paul Brinkmeier ac8e8a404a Add dbmate message about database version to schema 2025-09-22 16:06:22 +02:00
Paul Brinkmeier fa60291779 Bump nixpkgs 2025-09-22 11:58:59 +02:00
Paul Brinkmeier dbea1c549f Factor out doIndex 2025-09-22 11:58:41 +02:00
Paul Brinkmeier 82d8ce63d8 Remove Yore.Repl and Yore.Index, only run indexer at 3 AM 2025-09-20 15:33:54 +02:00
Paul Brinkmeier 67127fd28d Add schedule module move some ExceptTs around 2025-09-20 15:18:01 +02:00
Paul Brinkmeier 368bbb0124 Index current day in main 2025-09-19 09:00:19 +02:00
Paul Brinkmeier 22867bc229 Improve scripts 2025-09-19 08:55:56 +02:00
Paul Brinkmeier 78d317c7bd Make gFromEnv return Either String (...) 2025-09-17 19:57:25 +02:00
Paul Brinkmeier cf57a24497 Use envy in Main.hs 2025-09-17 18:56:34 +02:00
Paul Brinkmeier 01acb5b0e9 Support Text and Int in Envy 2025-09-17 18:56:13 +02:00
Paul Brinkmeier c69a1114b8 Provide type operator based envy API 2025-09-17 18:20:26 +02:00
17 changed files with 544 additions and 233 deletions
+27
View File
@@ -0,0 +1,27 @@
name: build
on:
pull_request:
jobs:
build:
container:
image: node:24-bookworm
env:
USER: node
HOME: /home/node
NIX_CONFIG: experimental-features = nix-command flakes
volumes:
- /var/lib/pbri/docker/nix_runner_nix:/nix
- /var/lib/pbri/docker/nix_runner_etc:/etc/nix
- /var/lib/pbri/docker/nix_runner_home_node:/home/node
options: --user 1000:1000
steps:
- name: Prep nix env
run: |
if [ ! -f ~/.nix-profile/etc/profile.d/nix.sh ]; then
curl -L https://nixos.org/nix/install | bash -s -- --no-daemon
fi
. ~/.nix-profile/etc/profile.d/nix.sh
env >> "$GITHUB_ENV"
- name: Checkout repo
uses: actions/checkout@v4
- run: nix build --log-format raw --print-build-logs .#yore
+40
View File
@@ -0,0 +1,40 @@
name: deliver
on:
push:
tags:
- 'v*'
jobs:
deliver:
container:
image: node:24-bookworm
env:
USER: node
HOME: /home/node
NIX_CONFIG: experimental-features = nix-command flakes
volumes:
- /var/lib/pbri/docker/nix_runner_nix:/nix
- /var/lib/pbri/docker/nix_runner_etc:/etc/nix
- /var/lib/pbri/docker/nix_runner_home_node:/home/node
options: --user 1000:1000
steps:
- name: Prep nix env
run: |
if [ ! -f ~/.nix-profile/etc/profile.d/nix.sh ]; then
curl -L https://nixos.org/nix/install | bash -s -- --no-daemon
fi
. ~/.nix-profile/etc/profile.d/nix.sh
env >> "$GITHUB_ENV"
- name: Checkout repo
uses: actions/checkout@v4
- name: Get image meta
run: nix build .#yore-meta -o /tmp/yore-meta
- name: Version check
run: |
VERSION=$(cat /tmp/yore-meta/version)
[ "$GITHUB_REF_NAME" = v"$VERSION" ]
- run: nix build --log-format raw --print-build-logs .#image -o image.tar.gz
- run: gunzip -c image.tar.gz > image.tar
- run: nix run .#crane -- auth login git.pbrinkmeier.de -u paul -p "$PASSWORD"
env:
PASSWORD: "${{ secrets.REGISTRY_PASSWORD }}"
- run: nix run .#crane -- push image.tar $(cat /tmp/yore-meta/image-tag)
+10 -4
View File
@@ -12,7 +12,7 @@ The rest of this section assumes you're in the dev shell.
### Running Postgres
`scripts/with-db $COMMAND` will create a Postgres cluster in a temporary directory with the credentials `user=yore-test dbname=yore-test port=5433`, start the Postgres server, run `$COMMAND` and then stop the Postgres erver.
`scripts/with-db $COMMAND` will create a Postgres cluster in a temporary directory with the credentials `user=yore-test dbname=yore-test port=5433`, start the Postgres server, apply migrations using `dbmate`, run `$COMMAND` and then stop the Postgres server.
On Linux, you need to have write permissions vor `/var/run/postgresql` to run it.
You can use this to run the tests in an entirely fresh database, e.g.:
@@ -24,6 +24,8 @@ You can also use it to run a database for development by choosing a `$COMMAND` t
```
BASE_DIR=./pgdata scripts/with-db psql "dbname=yore-test port=5433 user=yore-test"
# cabal repl with env vars set
BASE_DIR=./pgdata scripts/with-db cabal run
```
By setting `$BASE_DIR` you can persist the database for later runs.
@@ -39,6 +41,10 @@ By setting `$BASE_DIR` you can persist the database for later runs.
## TODO
- CI
- Docker container (in flake)
- try fourmolu
- [x] CI
- [x] Docker container (in flake)
- [x] try fourmolu
- [x] Test leap second handling in the `time` package
- [ ] Fix container shutdown delay
- [ ] Fix `responseFile` always returning 200
- [ ] use queue to sync logging of indexer and main thread
+152 -71
View File
@@ -1,37 +1,42 @@
{-# LANGUAGE DataKinds #-}
{-# LANGUAGE ExplicitNamespaces #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE OverloadedRecordDot #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE PatternSynonyms #-}
{-# LANGUAGE QuantifiedConstraints #-}
{-# LANGUAGE StandaloneDeriving #-}
{-# LANGUAGE TypeSynonymInstances #-}
module Main (main) where
import Control.Concurrent (forkIO)
import Control.Monad (forM, forM_)
import Control.Monad.IO.Class (liftIO)
import Control.Monad.Trans.Except (ExceptT (..), runExceptT)
import Control.Monad.Trans.Except (ExceptT (..), catchE, runExceptT)
import Data.Bifunctor (Bifunctor (..))
import Data.Proxy (Proxy (..))
import Data.Time (Day, toGregorian)
import Data.Text (Text)
import Data.Time (Day, LocalTime (..), TimeOfDay (..), ZonedTime (..), addDays, nominalDiffTimeToSeconds, toGregorian)
import GHC.Generics (Generic)
import Network.HTTP.Media ((//), (/:))
import Network.HTTP.Types (Status (..), mkStatus, status200)
import Network.HTTP.Types (Status (..))
import Network.Wai (Middleware, Request (..), responseStatus)
import Servant
( Accept (..)
, Capture
( Capture
, Get
, Handler
, MimeRender (..)
, Raw
, ServerError (..)
, ServerT
, Tagged (..)
, err404
, err500
, hoistServer
, runHandler
, serve
, (:<|>) (..)
, (:>)
, pattern MkHandler
)
import System.Exit (exitFailure)
import System.FilePath ((</>))
import Text.Blaze.Html.Renderer.Utf8 (renderHtml)
import Text.Blaze.Html5 ((!))
@@ -42,47 +47,102 @@ import qualified Data.ByteString.Lazy as LBS
import qualified Data.Text as Text
import qualified Data.Text.Encoding as Encoding
import qualified Database.PostgreSQL.Opium as Opium
import Network.Wai (Application, Request (..), Response, responseFile, responseLBS)
import qualified Network.Wai.Handler.Warp as Warp
import qualified Text.Blaze.Html5 as H
import qualified Text.Blaze.Html5.Attributes as A
import Yore.DB (DayFile (..))
import Envy (type (=@!), type (=@@), type (?))
import Yore.DB (DB, DayFile (..))
import Yore.Download (downloadInto)
import Yore.Error (Error (..))
import Yore.Schedule (schedule)
import Yore.Scrape (Issue (..), getIssuesByDay)
import Yore.Servant (GetSendfile, HTML, Sendfile (..))
import Yore.Time (addYears, getCurrentDay, getSecondsUntilMidnight)
import Data.Fixed (Pico)
import Data.Time.Clock.POSIX (getPOSIXTime)
import qualified Envy
import qualified Yore.DB as DB
import qualified Yore.Log as Log
import System.IO (hSetBuffering, BufferMode (..), stdout)
data Config f = Config
{ port :: Envy.Env f (Envy.Optional Integer 3000)
, downloadDir :: Envy.Env f (Envy.Optional String "download")
, factor :: Envy.Env f (Envy.Required Rational)
, factorFallback :: Envy.Env f (Envy.Optional Rational '(1, 1))
data ConfigT f = Config
{ yorePort :: f =@@ Int ? 3000
, yoreDownloadDir :: f =@@ FilePath ? "./download"
, yoreDb :: f =@! Text
}
deriving (Generic)
type Config = ConfigT Envy.Value
deriving instance Show Config
main :: IO ()
main = do
print . port =<< Envy.load @Config
print . downloadDir =<< Envy.load @Config
print . factor =<< Envy.load @Config
let
settings =
Warp.setLogger logger $
Warp.setPort 3000 $
Warp.setOnException onException Warp.defaultSettings
hSetBuffering stdout NoBuffering
db <- DB.initDB "host=localhost user=yore-test port=5433 dbname=yore-test"
Warp.runSettings settings $ serve (Proxy :: Proxy API) $ hoistServer (Proxy :: Proxy API) nt $ server db
cfg <-
Envy.load @ConfigT >>= \case
Left errs -> do
forM_ errs $ Log.error . printf "failed to read config: %s"
exitFailure
Right c ->
pure c
db <- DB.initDB cfg.yoreDb
_ <- runExceptT $ doIndex cfg db
_ <- forkIO $ runIndexer cfg db
runServer cfg db
runIndexer :: Config -> DB -> IO ()
runIndexer cfg db =
schedule shouldRunAt $ runExceptT $ doIndex cfg db
where
logger req status _ = do
Log.info $
printf
"%d %s"
(statusCode status)
(requestLine req)
shouldRunAt zt = case localTimeOfDay $ zonedTimeToLocalTime zt of
TimeOfDay 3 0 _ -> True
_ -> False
doIndex :: Config -> DB -> ExceptT Error IO ()
doIndex cfg db =
catchE
( DB.withTransaction db $ \conn -> do
forM_ [0, 1, 2] $ \i -> do
dayThen <- getTodayWithOffset (-100) i
indexDay cfg dayThen conn
)
(Log.error . show)
runServer :: Config -> DB -> IO ()
runServer cfg db =
Warp.runSettings settings $ loggerMiddleware $ serve (Proxy @API) $ hoistServer (Proxy @API) nt $ server cfg db
where
settings =
foldr
($)
Warp.defaultSettings
[ Warp.setPort cfg.yorePort
, Warp.setBeforeMainLoop $ Log.info $ printf "listening on port %d" cfg.yorePort
, Warp.setOnException onException
]
logger :: Request -> Status -> Pico -> IO ()
logger req status s = do
Log.info $ printf "%d %s %ss" (statusCode status) (requestLine req) (formatMetric s)
loggerMiddleware :: Middleware
loggerMiddleware app req respond = do
begin <- getPOSIXTime
app req $ \res -> do
rr <- respond res
end <- getPOSIXTime
logger req (responseStatus res) (nominalDiffTimeToSeconds (end - begin))
pure rr
onException mbReq ex = do
Log.error $ printf "unhandled exception%s: %s%s" (maybe "" ((" in " ++) . requestLine) mbReq) (show ex)
requestLine :: Request -> String
requestLine req =
@@ -91,8 +151,17 @@ main = do
(BS8.unpack $ requestMethod req)
(BS8.unpack $ rawPathInfo req <> rawQueryString req)
onException mbReq ex = do
Log.error $ printf "unhandled exception%s: %s%s" (maybe "" ((" in " ++) . requestLine) mbReq) (show ex)
formatMetric :: Pico -> String
formatMetric x = go (-9) prefixes
where
prefixes = ["p", "n", "u", "m", "", "k", "M", "G"]
go :: Int -> [String] -> String
go _ [] = show x
go e (p : ps)
| x < 10 ^^ e =
show @Int (floor $ x * 10 ^^ (-e + 3)) ++ p
| otherwise = go (e + 3) ps
nt :: ExceptT Error IO a -> Handler a
nt action = MkHandler $ do
@@ -104,11 +173,6 @@ nt action = MkHandler $ do
Right x ->
pure $ Right x
type API =
Get '[HTML] RootModel
:<|> "today" :> "issue" :> Capture "issue" Int :> Get '[HTML] RootModel
:<|> "api" :> "today" :> "issue" :> Capture "issue" Int :> "fz.pdf" :> Raw
toServerError :: Error -> ServerError
toServerError = \case
ConnectionError msg ->
@@ -123,39 +187,36 @@ toServerError = \case
encodeUtf8LBS :: String -> LBS.ByteString
encodeUtf8LBS = LBS.fromStrict . Encoding.encodeUtf8 . Text.pack
handlerToRaw :: ExceptT Error IO Response -> Tagged t Application
handlerToRaw handler = Tagged $ \_ respond -> do
r <- runHandler $ nt handler
case r of
Left e ->
respond $ responseLBS (mkStatus e.errHTTPCode $ BS8.pack e.errReasonPhrase) e.errHeaders e.errBody
Right response ->
respond response
type API =
Get '[HTML] RootModel
:<|> "today" :> "issue" :> Capture "issue" Int :> Get '[HTML] RootModel
:<|> "api" :> "today" :> "issue" :> Capture "issue" Int :> "fz.pdf" :> GetSendfile
server :: DB.DB -> ServerT API (ExceptT Error IO)
server db = rootR :<|> todayR :<|> apiTodayR
server :: Config -> DB -> ServerT API (ExceptT Error IO)
server cfg db = rootR :<|> todayR :<|> apiTodayR
where
rootR = todayR 0
todayR issue = do
dateThen <- ExceptT get100YearsAgo
count <- ExceptT $ DB.withConn db $ DB.getNumberOfIssues dateThen
dayFile <- ExceptT $ DB.withConn db $ DB.getDayFileByIssue dateThen issue
dateThen <- getTodayWithOffset (-100) 0
count <- DB.withConn db $ DB.getNumberOfIssues dateThen
dayFile <- DB.withConn db $ DB.getDayFileByIssue dateThen issue
pure $ RootModel dateThen dayFile issue count
apiTodayR issue = handlerToRaw $ do
dateThen <- ExceptT get100YearsAgo
dayFile <- ExceptT $ DB.withConn db $ DB.getDayFileByIssue dateThen issue
let fullPath = "download" </> dayFile.relative_path
apiTodayR issue = do
dateThen <- getTodayWithOffset (-100) 0
dayFile <- DB.withConn db $ DB.getDayFileByIssue dateThen issue
let fullPath = cfg.yoreDownloadDir </> dayFile.relative_path
secondsUntilMidnight <- liftIO getSecondsUntilMidnight
pure $
responseFile
status200
[ ("content-type", "application/pdf")
, ("cache-control", BS8.pack $ printf "public, max-age=%d" secondsUntilMidnight)
]
fullPath
Nothing
pure
Sendfile
{ headers =
[ ("content-type", "application/pdf")
, ("cache-control", BS8.pack $ printf "public, max-age=%d" secondsUntilMidnight)
]
, path =
fullPath
}
data RootModel = RootModel Day DB.DayFile Int Int
@@ -191,11 +252,31 @@ instance MimeRender HTML RootModel where
-- Utils
data HTML
getTodayWithOffset :: Integer -> Integer -> ExceptT Error IO Day
getTodayWithOffset yearOffset dayOffset =
ExceptT $
first (GenericError . (Text.pack (printf "can't go back %d years and go forward %d days: " (-yearOffset) dayOffset) <>))
. addYears yearOffset
. addDays dayOffset
<$> getCurrentDay
instance Accept HTML where
contentType _ = "text" // "html" /: ("charset", "utf-8")
get100YearsAgo :: IO (Either Error Day)
get100YearsAgo =
either (Left . GenericError . ("can't go back 100 years: " <>)) Right . addYears (-100) <$> getCurrentDay
indexDay :: Config -> Day -> Opium.Connection -> ExceptT Error IO ()
indexDay cfg dayThen conn = do
-- Transaction-level lock released automatically after transaction
DB.lift $ Opium.execute_ "SELECT pg_advisory_xact_lock(42);" conn
mbDi <- DB.readDayIndex dayThen conn
case mbDi of
Just _ ->
Log.info $ printf "index for %s already exists." (show dayThen)
Nothing -> do
Log.info $ printf "scraping issues for %s" (show dayThen)
issues <- liftIO $ getIssuesByDay dayThen
paths <- forM issues $ \issue -> do
Log.info $ printf "downloading %s" issue.url
path <- downloadInto cfg.yoreDownloadDir issue.url
pure (issue.label, path)
Log.info "creating DB entries"
dayIndex <- DB.createDayIndex dayThen conn
forM_ paths $ \(text, url) ->
DB.createDayFile dayIndex.day_index_id text url conn
Log.info "done."
+7
View File
@@ -1,3 +1,8 @@
\restrict CAC30PXzQJosjH02Spia1vzj9Ui3LB1Cq2HI8r0A4eu33HP8ngxbbM2SzRfTj0c
-- Dumped from database version 17.6
-- Dumped by pg_dump version 17.6
SET statement_timeout = 0;
SET lock_timeout = 0;
SET idle_in_transaction_session_timeout = 0;
@@ -124,6 +129,8 @@ ALTER TABLE ONLY yore.day_file
-- PostgreSQL database dump complete
--
\unrestrict CAC30PXzQJosjH02Spia1vzj9Ui3LB1Cq2HI8r0A4eu33HP8ngxbbM2SzRfTj0c
--
-- Dbmate schema migrations
Generated
+7 -7
View File
@@ -40,11 +40,11 @@
},
"nixpkgs": {
"locked": {
"lastModified": 1752536923,
"narHash": "sha256-fdgPZR7VFSSRIQKOJLcs3qCJBWM64Uak0gAGtWTYAd8=",
"lastModified": 1758518877,
"narHash": "sha256-1K1c6vNr/N1PfM77DqKINXQ/oCxA0W1ZJ80sfNzWu0w=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "c665e4d918eda5d78a175ed8d300809c44932160",
"rev": "033cc13ab4c1a60b3527b242755aa625001ff1f9",
"type": "github"
},
"original": {
@@ -64,11 +64,11 @@
]
},
"locked": {
"lastModified": 1756064767,
"narHash": "sha256-SaJK9VYc/oF5aBSMSW+We8TggER4+uTAHvc2ztnLpYo=",
"lastModified": 1759790870,
"narHash": "sha256-rEsaO38v2wvAThPzgbYPgnv29mu6GxTYLjgci3GYaO4=",
"ref": "main",
"rev": "af16429d827816cf9bec69026e13f50acc3d2691",
"revCount": 65,
"rev": "4db509c1b52ee7b5012abfef5e11e62beab27a16",
"revCount": 66,
"type": "git",
"url": "https://git.pbrinkmeier.de/paul/opium"
},
+51 -10
View File
@@ -26,11 +26,18 @@
};
# For building the package, we use only the files not ignored by Git as inputs.
# Also, flake.nix and flake.lock are not included to avoid rebuilds when
# Also, flake.nix, flake.lock and .gitea are not included to avoid rebuilds when
# working on them.
src = pkgs.lib.cleanSourceWith {
src = gitignore.lib.gitignoreSource ./.;
filter = path: type: builtins.elem (builtins.baseNameOf path) [ "flake.nix" "flake.lock" ];
src = ./.;
filter = gitignore.lib.gitignoreFilterWith {
basePath = ./.;
extraRules = ''
flake.nix
flake.lock
.gitea
'';
};
};
yore = pkgs.lib.pipe
@@ -51,16 +58,44 @@
# Return only the bin folder to curb image size
compose.justStaticExecutables
];
image =
pkgs.dockerTools.buildImage {
name = "git.pbrinkmeier.de/paul/yore";
tag = yore.version;
copyToRoot = [ pkgs.cacert ];
config = {
Cmd = [ "${entrypoint}/bin/run" ];
StopSignal = "SIGINT";
};
};
entrypoint = pkgs.writeShellApplication {
name = "run";
runtimeInputs = [ pkgs.coreutils pkgs.dbmate yore ];
text = ''
set -Eeuo pipefail
mkdir -p /tmp/yore-workspace/db
cp -r ${./db}/* /tmp/yore-workspace/db
cd /tmp/yore-workspace/db
dbmate up
# Replace bash and inherit PID 1
cd /
exec yore
'';
};
in {
packages = rec {
docker =
pkgs.dockerTools.buildImage {
name = "git.pbrinkmeier.de/paul/yore";
tag = yore.version;
config.Cmd = [ "${yore}/bin/yore" ];
};
default = yore;
inherit yore image;
yore-meta = pkgs.runCommand "yore-meta" {} ''
mkdir -p $out
echo -n ${yore.version} > $out/version
echo -n ${image.drvAttrs.imageName}:${image.drvAttrs.imageTag} > $out/image-tag
'';
opium_ = opium.packages.${system}.opium;
};
@@ -82,5 +117,11 @@
overrides = addOpium;
}).env;
};
apps = {
crane = {
type = "app";
program = "${pkgs.crane}/bin/crane";
};
};
});
}
+11 -3
View File
@@ -5,19 +5,27 @@ if [ -z "$BASE_DIR" ]; then
fi
export PGDATA="$BASE_DIR/pgdata"
export DB_USER=yore-test
export DB_DBNAME=yore-test
export DB_PORT=5433
# On error or exit: Stop the DB server
stop_db_server() {
pg_ctl stop -o "-p 5433" --wait
pg_ctl stop -o "-p ${DB_PORT}" --wait
}
trap stop_db_server EXIT
if [ ! -s "$PGDATA/PG_VERSION" ]; then
initdb --username=yore-test --auth=trust || true
initdb --username="${DB_USER}" --auth=trust || true
DO_CREATE_DB=1
fi
pg_ctl start -o "-p 5433" --wait
if [ -n "$DO_CREATE_DB" ]; then
createdb -p 5433 yore-test --username=yore-test || true
createdb -p "${DB_PORT}" "${DB_DBNAME}" --username="${DB_USER}" || true
fi
DATABASE_URL="postgres://${DB_USER}@localhost:${DB_PORT}/${DB_DBNAME}?sslmode=disable" dbmate up
export YORE_DB="host=localhost port=${DB_PORT} user=${DB_USER} dbname=${DB_DBNAME}"
"$@"
+98 -18
View File
@@ -1,53 +1,91 @@
{-# LANGUAGE DataKinds #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE TypeFamilies #-}
{-# LANGUAGE TypeOperators #-}
{-# LANGUAGE UndecidableInstances #-}
module Envy (Env, Required, Optional, load) where
module Envy
( type (=@@)
, type (=@!)
, type (?)
, type (=@?)
, ReadEnvVar (..)
, Required
, Optional
, load
, ConfigVariant (..)
, MyNothing
, OptionalMaybe
) where
import Data.Bifunctor (first)
import Data.Char (isLower, isUpper, toUpper)
import Data.Kind (Type)
import Data.Proxy (Proxy (..))
import Data.Ratio ((%))
import Data.Text (Text)
import Data.Typeable (Typeable, typeRep)
import GHC.Generics (C, D, Generic, K1 (..), M1 (..), Meta (..), Rec0, Rep, S, to, (:*:) (..))
import GHC.TypeLits (KnownNat, KnownSymbol, natVal, symbolVal)
import GHC.TypeLits (KnownNat, KnownSymbol, natVal, symbolVal, type (<=))
import System.Environment (getEnvironment)
import Text.Printf (printf)
import Text.Read (readEither)
import qualified Data.Text as Text
data ConfigVariant = Spec | Value
data Required t
data Optional t d
type family Env v t :: Type where
Env Spec t = t
Env Value (Required t) = t
Env Value (Optional t d) = t
type family v =@@ t :: Type where
Spec =@@ t = t
Value =@@ (Required t) = t
Value =@@ (Optional t d) = t
type OptionalMaybe t = Optional (Maybe t) MyNothing
-- No associativity - these are not supposed to be chained
infix 6 =@@
infix 7 ?
type v =@! t = v =@@ Required t
type v =@? t = v =@@ OptionalMaybe t
type t ? d = Optional t d
load
:: forall (m :: ConfigVariant -> Type). (Generic (m Value), GFromEnv (Rep (m Spec)) (Rep (m Value))) => IO (m Value)
:: forall (m :: ConfigVariant -> Type)
. (Generic (m Value), GFromEnv (Rep (m Spec)) (Rep (m Value)))
=> IO (Either [String] (m Value))
load = do
env <- getEnvironment
pure $ to $ gFromEnv @(Rep (m Spec)) @(Rep (m Value)) Proxy env
pure $ to <$> gFromEnv @(Rep (m Spec)) @(Rep (m Value)) Proxy env
class GFromEnv spec value where
gFromEnv :: Proxy (spec c) -> [(String, String)] -> value c
gFromEnv :: Proxy (spec c) -> [(String, String)] -> Either [String] (value c)
instance
(GFromEnv i o)
=> GFromEnv (M1 D b i) (M1 D d o)
where
gFromEnv Proxy env = M1 $ gFromEnv @i @o Proxy env
gFromEnv Proxy env = M1 <$> gFromEnv @i @o Proxy env
instance
(GFromEnv i o)
=> GFromEnv (M1 C b i) (M1 C d o)
where
gFromEnv Proxy env = M1 $ gFromEnv @i @o Proxy env
gFromEnv Proxy env = M1 <$> gFromEnv @i @o Proxy env
instance (GFromEnv i1 o1, GFromEnv i2 o2) => GFromEnv (i1 :*: i2) (o1 :*: o2) where
gFromEnv Proxy env = gFromEnv @i1 @o1 Proxy env :*: gFromEnv @i2 @o2 Proxy env
gFromEnv Proxy env =
case (eLhs, eRhs) of
(Left errs1, Right _) -> Left errs1
(Right _, Left errs2) -> Left errs2
(Left errs1, Left errs2) -> Left $ errs1 ++ errs2
(Right lhs, Right rhs) -> Right $ lhs :*: rhs
where
eLhs = gFromEnv @i1 @o1 Proxy env
eRhs = gFromEnv @i2 @o2 Proxy env
instance
(KnownSymbol sym, EnvVarSpec s t)
@@ -55,18 +93,17 @@ instance
(M1 S (MetaSel (Just sym) su ss lz) (Rec0 s))
(M1 S meta2 (Rec0 t))
where
gFromEnv Proxy env = case decodeEnvVar @s @t Proxy varName $ lookup varName env of
Left err -> error err
Right value -> M1 $ K1 value
gFromEnv Proxy env =
M1 . K1 <$> first (: []) (decodeEnvVar @s @t Proxy varName (lookup varName env))
where
varName = symbolVal $ Proxy @sym
varName = selectorNameToEnvVarName $ symbolVal $ Proxy @sym
class EnvVarSpec specTy valueTy where
decodeEnvVar :: Proxy specTy -> String -> Maybe String -> Either String valueTy
instance (Typeable t, ReadEnvVar t) => EnvVarSpec (Required t) t where
decodeEnvVar Proxy varName = \case
Nothing -> Left $ printf "%s is missing" varName
Nothing -> Left $ printf "env var %s is missing" varName
Just str -> readEnvVarWithContext varName str
instance (Typeable t, ReadEnvVar t, ProvidesDefault t d) => EnvVarSpec (Optional t d) t where
@@ -81,7 +118,7 @@ readEnvVarWithContext :: forall t. (Typeable t, ReadEnvVar t) => String -> Strin
readEnvVarWithContext varName str =
first (context ++) $ readEnvVar str
where
context = printf "%s='%s' is not a valid %s: " varName str (show $ typeRep $ Proxy @t)
context = printf "env var %s='%s' is not a valid %s: " varName str (show $ typeRep $ Proxy @t)
class ProvidesDefault t d where
getDefault :: Proxy d -> t
@@ -89,17 +126,60 @@ class ProvidesDefault t d where
instance ReadEnvVar Integer where
readEnvVar = readEither
-- TODO: Support negative integers (e.g. (Negate n))
instance (KnownNat n) => ProvidesDefault Integer n where
getDefault Proxy = natVal (Proxy @n)
instance ReadEnvVar Int where
readEnvVar = readEither
-- TODO: Support negative integers (e.g. (Negate n))
-- This instance assumes that @Int@ is a 64-bit integer and enforces its range on the type level.
instance (KnownNat n, n <= 9223372036854775807) => ProvidesDefault Int n where
getDefault Proxy = fromInteger $ natVal (Proxy @n)
instance ReadEnvVar String where
readEnvVar = Right
instance (KnownSymbol s) => ProvidesDefault String s where
getDefault Proxy = symbolVal (Proxy @s)
instance ReadEnvVar Text where
readEnvVar = fmap Text.pack . readEnvVar
instance (KnownSymbol s) => ProvidesDefault Text s where
getDefault Proxy = Text.pack $ getDefault (Proxy @s)
instance ReadEnvVar Rational where
readEnvVar = readEither
instance (KnownNat num, KnownNat denom) => ProvidesDefault Rational '(num, denom) where
getDefault Proxy = natVal (Proxy @num) % natVal (Proxy @denom)
instance ReadEnvVar Double where
readEnvVar = readEither
instance (KnownNat num, KnownNat denom) => ProvidesDefault Double '(num, denom) where
getDefault Proxy = fromRational $ getDefault @Rational @'(num, denom) Proxy
instance (ReadEnvVar t) => ReadEnvVar (Maybe t) where
readEnvVar = fmap Just . readEnvVar
instance (ProvidesDefault t d) => ProvidesDefault (Maybe t) (Just d) where
getDefault Proxy = Just $ getDefault @t @d Proxy
data MyNothing
instance ProvidesDefault (Maybe t) MyNothing where
getDefault Proxy = Nothing
-- Converts camelCase names to UPPER_SNAKE_CASE.
selectorNameToEnvVarName :: String -> String
selectorNameToEnvVarName = go
where
go [] =
[]
go (c0 : c1 : rest)
| isUpper c0 && isLower c1 = '_' : c0 : toUpper c1 : go rest
go (c0 : rest) =
toUpper c0 : go rest
+49 -20
View File
@@ -9,6 +9,7 @@ module Yore.DB
, Error (..)
, initDB
, withConn
, withTransaction
, DayIndex (..)
, DayFile (..)
, createDayFile
@@ -17,9 +18,11 @@ module Yore.DB
, readDayPaths
, getDayFileByIssue
, getNumberOfIssues
, lift
) where
import Control.Concurrent (getNumCapabilities)
import Control.Monad.Trans.Except (ExceptT (..), catchE, runExceptT, throwE)
import Data.Functor.Identity (Identity (..))
import Data.Pool (Pool, defaultPoolConfig, newPool, withResource)
import Data.Text (Text)
@@ -28,6 +31,7 @@ import GHC.Generics (Generic)
import qualified Database.PostgreSQL.Opium as Opium
import Data.Bifunctor (Bifunctor (..))
import Yore.Error (Error (..))
newtype DB = DB (Pool (Either Opium.ConnectionError Opium.Connection))
@@ -45,13 +49,35 @@ initDB connString = do
maxResources
)
withConn :: DB -> (Opium.Connection -> IO (Either Opium.Error a)) -> IO (Either Error a)
-- TODO: This should probably also do something like bracket...
-- For now let's assume no exceptions are thrown in f.
withConn :: DB -> (Opium.Connection -> ExceptT Error IO a) -> ExceptT Error IO a
withConn (DB connPool) f =
withResource connPool $ \case
ExceptT $ withResource connPool $ \case
Left connectionError ->
pure $ Left $ ConnectionError connectionError
Right conn ->
either (Left . DBError) Right <$> f conn
runExceptT $ f conn
withTransaction :: DB -> (Opium.Connection -> ExceptT Error IO a) -> ExceptT Error IO a
withTransaction db f =
withConn db $ \conn ->
( do
lift $ Opium.execute_ "BEGIN" conn
x <- f conn
lift $ Opium.execute_ "COMMIT" conn
pure x
)
`catchE` ( \err -> do
-- rollback open transactions and release transaction level locks...
lift $ Opium.execute_ "ROLLBACK" conn
-- ...then rethrow the error so the app can handle it too.
throwE err
)
lift :: IO (Either Opium.Error a) -> ExceptT Error IO a
lift f =
ExceptT $ first DBError <$> f
data DayIndex = DayIndex
{ day_index_id :: Int
@@ -72,44 +98,47 @@ newtype CountResult = CountResult
}
deriving (Show, Generic, Opium.FromRow)
createDayIndex :: Day -> Opium.Connection -> IO (Either Opium.Error DayIndex)
createDayIndex :: Day -> Opium.Connection -> ExceptT Error IO DayIndex
createDayIndex date =
ex runIdentity
. Opium.fetch
"INSERT INTO yore.day_index (day) VALUES ($1) RETURNING *"
(Identity date)
createDayFile :: Int -> Text -> FilePath -> Opium.Connection -> IO (Either Opium.Error ())
createDayFile :: Int -> Text -> FilePath -> Opium.Connection -> ExceptT Error IO ()
createDayFile dayId label path =
Opium.execute
"INSERT INTO yore.day_file (day_index_id, label, relative_path) VALUES ($1, $2, $3)"
(dayId, label, path)
lift
. Opium.execute
"INSERT INTO yore.day_file (day_index_id, label, relative_path) VALUES ($1, $2, $3)"
(dayId, label, path)
readDayIndex :: Day -> Opium.Connection -> IO (Either Opium.Error (Maybe DayIndex))
readDayIndex :: Day -> Opium.Connection -> ExceptT Error IO (Maybe DayIndex)
readDayIndex date =
Opium.fetch
"SELECT * FROM yore.day_index WHERE day = $1"
(Identity date)
lift
. Opium.fetch
"SELECT * FROM yore.day_index WHERE day = $1"
(Identity date)
readDayPaths :: Day -> Opium.Connection -> IO (Either Opium.Error [(DayIndex, DayFile)])
readDayPaths :: Day -> Opium.Connection -> ExceptT Error IO [(DayIndex, DayFile)]
readDayPaths date =
Opium.fetch
"SELECT * FROM yore.day_index LEFT JOIN yore.day_file USING (day_index_id) WHERE day = $1"
(Identity date)
lift
. Opium.fetch
"SELECT * FROM yore.day_index LEFT JOIN yore.day_file USING (day_index_id) WHERE day = $1"
(Identity date)
getDayFileByIssue :: Day -> Int -> Opium.Connection -> IO (Either Opium.Error DayFile)
getDayFileByIssue :: Day -> Int -> Opium.Connection -> ExceptT Error IO DayFile
getDayFileByIssue date issue =
ex runIdentity
. Opium.fetch
"SELECT yore.day_file.* FROM yore.day_index LEFT JOIN yore.day_file USING (day_index_id) WHERE day = $1 ORDER BY day_file_id LIMIT 1 OFFSET $2"
(date, issue)
getNumberOfIssues :: Day -> Opium.Connection -> IO (Either Opium.Error Int)
getNumberOfIssues :: Day -> Opium.Connection -> ExceptT Error IO Int
getNumberOfIssues date =
ex (count . runIdentity)
. Opium.fetch
"SELECT count(*) FROM yore.day_index LEFT JOIN yore.day_file USING (day_index_id) WHERE day = $1"
(Identity date)
ex :: (a -> b) -> IO (Either e a) -> IO (Either e b)
ex = fmap . fmap
ex :: (a -> b) -> IO (Either Opium.Error a) -> ExceptT Error IO b
ex f = fmap f . lift
+19 -8
View File
@@ -2,7 +2,8 @@
module Yore.Download (downloadInto) where
import Control.Monad (guard)
import Control.Exception (Exception, IOException, catch)
import Control.Monad.Trans.Except (ExceptT (..))
import Data.Maybe (fromJust)
import Data.Text (Text)
import Network.HTTP.Req hiding (queryParam)
@@ -14,6 +15,8 @@ import qualified Data.Text as Text
import qualified Data.UUID.V4 as UUID
import qualified Text.URI as URI
import Yore.Error (Error (..))
-- | Download a URL and save it to a directory.
-- Returns the path of the downloaded file relative to the directory.
downloadInto
@@ -21,22 +24,30 @@ downloadInto
-- ^ Directory where to store the file.
-> Text
-- ^ The URL to download.
-> IO FilePath
downloadInto downloadDir textUrl = download >>= save
-> ExceptT Error IO FilePath
downloadInto downloadDir textUrl =
download >>= save
where
download :: IO LBS.ByteString
download = do
download :: ExceptT Error IO LBS.ByteString
download = toExceptT (handle @HttpException) $ do
uri <- URI.mkURI textUrl
let (url, opts) = fromJust $ useHttpsURI uri
res <- runReq defaultHttpConfig $ req GET url NoReqBody lbsResponse opts
guard $ responseStatusCode res == 200
pure $ responseBody res
save :: LBS.ByteString -> IO FilePath
save contents = do
save :: LBS.ByteString -> ExceptT Error IO FilePath
save contents = toExceptT (handle @IOException) $ do
let suffix = takeExtension $ Text.unpack textUrl
uuid <- UUID.nextRandom
let fullPath = downloadDir </> show uuid <.> suffix
createDirectoryIfMissing True downloadDir
LBS.writeFile fullPath contents
pure $ makeRelative downloadDir fullPath
toExceptT :: (Exception e) => (e -> Error) -> IO a -> ExceptT Error IO a
toExceptT showException f =
ExceptT $ (Right <$> f) `catch` (pure . Left . showException)
handle :: (Show e) => e -> Error
handle =
GenericError . Text.pack . show
-24
View File
@@ -1,24 +0,0 @@
{-# LANGUAGE OverloadedRecordDot #-}
{-# LANGUAGE OverloadedStrings #-}
module Yore.Index (createEntries) where
import Control.Exception (Exception, throwIO)
import Control.Monad (forM_)
import Data.Text (Text)
import Data.Time (Day)
import qualified Database.PostgreSQL.Opium as Opium
import qualified Yore.DB as DB
createEntries :: Day -> [(Text, FilePath)] -> Opium.Connection -> IO ()
createEntries date urls conn = do
confidently $ Opium.execute_ "BEGIN" conn
dayIndex <- confidently $ DB.createDayIndex date conn
forM_ urls $ \(text, url) ->
confidently $ DB.createDayFile dayIndex.day_index_id text url conn
confidently $ Opium.execute_ "COMMIT" conn
confidently :: (Exception e) => IO (Either e a) -> IO a
confidently action = action >>= either throwIO pure
+6 -5
View File
@@ -1,21 +1,22 @@
module Yore.Log (Yore.Log.error, info) where
import Control.Monad.IO.Class (MonadIO (..))
import Data.Time (ZonedTime, getZonedTime)
import Data.Time.Format (defaultTimeLocale, formatTime)
import GHC.Stack (HasCallStack, SrcLoc (..), callStack, getCallStack)
import Text.Printf (printf)
info :: (HasCallStack) => String -> IO ()
info :: (HasCallStack, MonadIO m) => String -> m ()
info = doLog "INF"
error :: (HasCallStack) => String -> IO ()
error :: (HasCallStack, MonadIO m) => String -> m ()
error = doLog "ERR"
doLog :: (HasCallStack) => String -> String -> IO ()
doLog level msg = do
doLog :: (HasCallStack, MonadIO m) => String -> String -> m ()
doLog level msg = liftIO $ do
now <- getZonedTime
let location = getLocation $ getCallStack callStack
printf "(%s) (%s) (%s) %s\n" (iso8601Show now) location level msg
printf "(%s) (%s) (%s) %s\n" (iso8601Show now) level location msg
where
getLocation :: [(String, SrcLoc)] -> String
-- First entry is always a function from this module, skip it
-59
View File
@@ -1,59 +0,0 @@
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE OverloadedRecordDot #-}
{-# LANGUAGE OverloadedStrings #-}
module Yore.Repl (connect, exec, getToday, indexDay, addYears) where
import Control.Exception (bracket, throwIO)
import Control.Monad (forM)
import Data.Time (Day, getZonedTime, localDay, zonedTimeToLocalTime)
import Text.Printf (printf)
import qualified Database.PostgreSQL.Opium as Opium
import Yore.DB (DayIndex)
import Yore.Download (downloadInto)
import Yore.Index (createEntries)
import Yore.Scrape (Issue (..), getIssuesByDay)
import Yore.Time (addYears)
import qualified Yore.DB as DB
connect :: IO Opium.Connection
connect =
Opium.connect "host=localhost port=5433 user=yore-test" >>= \case
Left e -> error $ show e
Right c -> pure c
exec :: (Opium.Connection -> IO a) -> IO a
exec = bracket connect Opium.close
getToday :: IO [Issue]
getToday = do
now <- getZonedTime
print now
let currentDay = localDay $ zonedTimeToLocalTime now
case addYears (-100) currentDay of
Right then_ ->
getIssuesByDay then_
Left _ ->
pure []
indexDay :: Day -> IO ()
indexDay date = exec $ \conn -> do
either throwIO pure =<< Opium.execute_ "SELECT pg_advisory_lock(42);" conn
printf "Checking whether %s already exists...\n" (show date)
mbDi <- either throwIO pure =<< DB.readDayIndex date conn
case mbDi of
Just (_ :: DayIndex) ->
putStrLn "Nothing to do."
Nothing -> do
putStrLn "Retrieving URLs..."
issues <- getIssuesByDay date
paths <- forM issues $ \issue -> do
printf "Downloading %s...\n" issue.url
path <- downloadInto "./download" issue.url
pure (issue.label, path)
putStrLn "Creating DB entries..."
createEntries date paths conn
putStrLn "Done."
+22
View File
@@ -0,0 +1,22 @@
module Yore.Schedule (schedule) where
import Control.Concurrent (forkIO, threadDelay)
import Control.Monad (when)
import Data.Functor (($>))
import Data.Time (ZonedTime, getZonedTime, nominalDiffTimeToSeconds, secondsToNominalDiffTime)
import Data.Time.Clock.POSIX (getPOSIXTime)
schedule :: (ZonedTime -> Bool) -> IO a -> IO ()
schedule shouldRunAt f = everyMinute $ \now -> when (shouldRunAt now) (f $> ())
everyMinute :: (ZonedTime -> IO ()) -> IO ()
everyMinute f = do
-- Use POSIX time to avoid having to handle leap seconds
now <- getPOSIXTime
let posixSeconds :: Int
posixSeconds = floor $ nominalDiffTimeToSeconds now
lastFullMinute = secondsToNominalDiffTime $ fromIntegral $ posixSeconds - posixSeconds `mod` 60
secondsSinceLastFullMinute = now - lastFullMinute
threadDelay $ ceiling $ (60 - secondsSinceLastFullMinute) * 1_000_000
_ <- forkIO $ f =<< getZonedTime
everyMinute f
+38
View File
@@ -0,0 +1,38 @@
{-# LANGUAGE OverloadedRecordDot #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE TypeFamilies #-}
module Yore.Servant (GetSendfile, Sendfile (..), HTML) where
import Network.HTTP.Media ((//), (/:))
import Network.HTTP.Types (ResponseHeaders, status200)
import Network.Wai (responseFile)
import Servant (Accept (..), HasServer (..))
import Servant.Server.Internal.Delayed (runAction)
import Servant.Server.Internal.RouteResult (RouteResult (..))
import Servant.Server.Internal.Router (Router' (..))
-- | Servant route type that responds with a file using @sendfile@.
data GetSendfile
data Sendfile = Sendfile
{ headers :: ResponseHeaders
-- ^ The headers to send along with the file, e.g. @content-type@ or cache information.
, path :: FilePath
-- ^ The file to respond with.
}
deriving (Show)
instance HasServer GetSendfile context where
type ServerT GetSendfile m = m Sendfile
hoistServerWithContext _ _ nt = nt
route _ _ action = RawRouter $ \env request respond ->
runAction action env request respond $ \sendfile ->
Route $ responseFile status200 sendfile.headers sendfile.path Nothing
data HTML
instance Accept HTML where
contentType _ = "text" // "html" /: ("charset", "utf-8")
+7 -4
View File
@@ -1,7 +1,7 @@
cabal-version: 3.4
name: yore
version: 0
version: 0.0.6
author: Paul Brinkmeier
maintainer: hallo@pbrinkmeier.de
copyright: 2023 Paul Brinkmeier
@@ -37,11 +37,11 @@ library
, Yore.DB
, Yore.Download
, Yore.Error
, Yore.Index
, Yore.Log
, Yore.Repl
, Yore.Schedule
, Yore.Scrape
, Yore.Time
, Yore.Servant
hs-source-dirs:
src
build-depends:
@@ -50,16 +50,20 @@ library
, directory
, filepath
, html-parse
, http-media
, http-types
, lens
, modern-uri
, mtl
, opium
, req
, resource-pool
, servant-server
, text
, time
, transformers
, uuid
, wai
executable yore
import: shared-options
@@ -78,7 +82,6 @@ executable yore
, bytestring
, filepath
, opium
, http-media
, http-types
, servant-server
, text