Store cook log DB in writable data dir, not read-only recipes mount
Build and Deploy / build-and-deploy (push) Successful in 2m43s
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:
+3
-1
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user