Store cook log DB in writable data dir, not read-only recipes mount
Build and Deploy / build-and-deploy (push) Successful in 2m43s

The cook log SQLite DB was created at recipeDir/cook-log.db. In the Docker
deployment /recipes is mounted read-only, so SQLite could never create the
file there — the cook log has never worked in the container. The writable
/data volume was mounted but unused.

- initDb now creates the DB's parent directory if missing, so startup
  initialization is self-contained and idempotent.
- app takes a dataDir and places cook-log.db there.
- Add optional --data-dir flag (defaults to the recipe dir, preserving
  local-dev behavior); Docker CMD passes --data-dir /data.

Verified end-to-end as root against a read-only recipe dir and a
non-existent data dir: the dir and DB are created and cook-log POST/GET
round-trips.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-12 21:53:35 -04:00
parent 3c3b6b853a
commit 460b7d0fdc
5 changed files with 45 additions and 11 deletions
+3 -1
View File
@@ -32,4 +32,6 @@ WORKDIR /recipes
EXPOSE 8080 EXPOSE 8080
CMD ["/usr/local/bin/roux-server", "--host", "0.0.0.0", "--config-file", "/roux/config.yaml", "--port", "8080", "--recipe-dir", "/recipes"] # The cook log DB is written to /data (a writable volume); /recipes is mounted
# read-only, so the DB cannot live there.
CMD ["/usr/local/bin/roux-server", "--host", "0.0.0.0", "--config-file", "/roux/config.yaml", "--port", "8080", "--recipe-dir", "/recipes", "--data-dir", "/data"]
+18 -1
View File
@@ -5,6 +5,7 @@ Starts a Warp HTTP server and serves recipe content.
-} -}
module Main (main) where module Main (main) where
import Data.Maybe (fromMaybe)
import Data.String (fromString) import Data.String (fromString)
import qualified Network.Wai.Handler.Warp as Warp import qualified Network.Wai.Handler.Warp as Warp
import qualified Options.Applicative as Opts import qualified Options.Applicative as Opts
@@ -21,6 +22,7 @@ data Options = Options
{ optHost :: String { optHost :: String
, optPort :: Int , optPort :: Int
, optRecipeDir :: FilePath , optRecipeDir :: FilePath
, optDataDir :: Maybe FilePath
, optConfigFile :: FilePath , optConfigFile :: FilePath
} }
@@ -51,6 +53,16 @@ optionsParser =
<> Opts.value "recipes" <> Opts.value "recipes"
<> Opts.showDefault <> Opts.showDefault
) )
<*> Opts.optional
( Opts.strOption
( Opts.long "data-dir"
<> Opts.metavar "DIR"
<> Opts.help
"Writable directory for app state (the cook log DB). \
\Created on startup if missing. Defaults to the recipe directory. \
\Set this when the recipe directory is read-only (e.g. the Docker deployment mounts recipes read-only and passes --data-dir /data)."
)
)
<*> Opts.strOption <*> Opts.strOption
( Opts.long "config-file" ( Opts.long "config-file"
<> Opts.metavar "FILE" <> Opts.metavar "FILE"
@@ -78,9 +90,14 @@ main = do
(fromString (optHost opts)) (fromString (optHost opts))
Warp.defaultSettings Warp.defaultSettings
-- Cook log DB (and any future state) lives here; falls back to the recipe
-- directory when --data-dir is not given.
let dataDir = fromMaybe (optRecipeDir opts) (optDataDir opts)
putStrLn $ "[roux] scanning recipes from " <> optRecipeDir opts putStrLn $ "[roux] scanning recipes from " <> optRecipeDir opts
putStrLn $ "[roux] listening on " <> optHost opts <> ":" <> show (optPort opts) putStrLn $ "[roux] listening on " <> optHost opts <> ":" <> show (optPort opts)
putStrLn $ "[roux] config file: " <> optConfigFile opts putStrLn $ "[roux] config file: " <> optConfigFile opts
putStrLn $ "[roux] data dir: " <> dataDir
-- Load config (file may be missing; LLM features disabled gracefully) -- Load config (file may be missing; LLM features disabled gracefully)
configResult <- Config.loadConfig (optConfigFile opts) configResult <- Config.loadConfig (optConfigFile opts)
@@ -90,5 +107,5 @@ main = do
case configResult of case configResult of
Left err -> putStrLn $ "[roux] warning: " <> err <> " — LLM features disabled" Left err -> putStrLn $ "[roux] warning: " <> err <> " — LLM features disabled"
Right _ -> pure () Right _ -> pure ()
waiApp <- Roux.app (optRecipeDir opts) config waiApp <- Roux.app (optRecipeDir opts) dataDir config
Warp.runSettings settings waiApp Warp.runSettings settings waiApp
+2
View File
@@ -5,5 +5,7 @@ services:
ports: ports:
- "127.0.0.1:8080:8080" - "127.0.0.1:8080:8080"
volumes: volumes:
# Cooklang source of truth, managed externally — mounted read-only.
- /srv/roux/recipes:/recipes:ro - /srv/roux/recipes:/recipes:ro
# Writable app state (the cook log SQLite DB); see --data-dir in the Dockerfile CMD.
- /srv/roux/data:/data - /srv/roux/data:/data
+12 -3
View File
@@ -2,8 +2,8 @@
{- | Cook log: track which recipes were cooked when, with optional notes. {- | Cook log: track which recipes were cooked when, with optional notes.
Uses sqlite-simple for persistence. The database file lives alongside the Uses sqlite-simple for persistence. The database file lives in the writable
recipe directory as @cook-log.db@. data directory as @cook-log.db@ (see 'initDb').
-} -}
module Roux.CookLog ( module Roux.CookLog (
CookEntry (..), CookEntry (..),
@@ -22,6 +22,8 @@ import Data.Time.Clock (getCurrentTime)
import qualified Data.Time.Format as Time import qualified Data.Time.Format as Time
import Database.SQLite.Simple (Connection, FromRow (..), Only (..), Query) import Database.SQLite.Simple (Connection, FromRow (..), Only (..), Query)
import qualified Database.SQLite.Simple as SQL import qualified Database.SQLite.Simple as SQL
import System.Directory (createDirectoryIfMissing)
import System.FilePath (takeDirectory)
-- | A single cooking log entry. -- | A single cooking log entry.
data CookEntry = CookEntry data CookEntry = CookEntry
@@ -70,9 +72,16 @@ instance FromJSON CookEntry where
Nothing -> fail "invalid date" Nothing -> fail "invalid date"
_ -> fail "expected YYYY-MM-DD" _ -> fail "expected YYYY-MM-DD"
-- | Open or create the DB, ensuring the table exists. {- | Open or create the DB, ensuring the containing directory, the DB file, and
the table all exist. Safe to call on every startup: 'createDirectoryIfMissing'
and @CREATE TABLE IF NOT EXISTS@ are both no-ops when the targets already exist,
and SQLite creates the file itself on first open. The parent directory must be
writable — in the Docker deployment this is the @/data@ volume, not the
read-only @/recipes@ mount.
-}
initDb :: FilePath -> IO Connection initDb :: FilePath -> IO Connection
initDb dbPath = do initDb dbPath = do
createDirectoryIfMissing True (takeDirectory dbPath)
conn <- SQL.open dbPath conn <- SQL.open dbPath
SQL.execute_ conn createTableSQL SQL.execute_ conn createTableSQL
pure conn pure conn
+10 -6
View File
@@ -74,12 +74,15 @@ readRequestBody req = do
then pure (reverse acc) then pure (reverse acc)
else gather (chunk : acc) else gather (chunk : acc)
{- | Build the WAI 'Application' given a path to the recipe directory and config. {- | Build the WAI 'Application' given the recipe directory, a writable data
directory, and config.
Recipes are scanned at startup and then watched live via fsnotify. Recipes are scanned at startup and then watched live via fsnotify. The cook log
database is created (if absent) in @dataDir@, which must be writable — the
recipe directory may be mounted read-only (as it is in the Docker deployment).
-} -}
app :: FilePath -> RouxConfig -> IO Wai.Application app :: FilePath -> FilePath -> RouxConfig -> IO Wai.Application
app recipeDir config = do app recipeDir dataDir config = do
putStrLn $ "[roux] scanning recipes from " <> recipeDir putStrLn $ "[roux] scanning recipes from " <> recipeDir
recipes <- Idx.scanRecipes recipeDir recipes <- Idx.scanRecipes recipeDir
let count = length recipes let count = length recipes
@@ -101,8 +104,9 @@ app recipeDir config = do
forkIO $ forkIO $
watchRecipes absDir state changeLog `catch` \e -> watchRecipes absDir state changeLog `catch` \e ->
putStrLn $ "[roux] watcher thread crashed: " <> show (e :: SomeException) putStrLn $ "[roux] watcher thread crashed: " <> show (e :: SomeException)
-- Initialize cook log database -- Initialize cook log database in the writable data directory (initDb
let dbPath = recipeDir </> "cook-log.db" -- creates the directory and file if they are not already present).
let dbPath = dataDir </> "cook-log.db"
db <- CookLog.initDb dbPath db <- CookLog.initDb dbPath
putStrLn $ "[roux] cook log DB: " <> dbPath putStrLn $ "[roux] cook log DB: " <> dbPath
pure (router absDir config state changeLog db) pure (router absDir config state changeLog db)