diff --git a/Dockerfile b/Dockerfile index bb88217..2c1a0cb 100644 --- a/Dockerfile +++ b/Dockerfile @@ -32,4 +32,6 @@ WORKDIR /recipes 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"] diff --git a/app/Main.hs b/app/Main.hs index ef0a579..4216f3c 100644 --- a/app/Main.hs +++ b/app/Main.hs @@ -5,6 +5,7 @@ Starts a Warp HTTP server and serves recipe content. -} module Main (main) where +import Data.Maybe (fromMaybe) import Data.String (fromString) import qualified Network.Wai.Handler.Warp as Warp import qualified Options.Applicative as Opts @@ -21,6 +22,7 @@ data Options = Options { optHost :: String , optPort :: Int , optRecipeDir :: FilePath + , optDataDir :: Maybe FilePath , optConfigFile :: FilePath } @@ -51,6 +53,16 @@ optionsParser = <> Opts.value "recipes" <> 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.long "config-file" <> Opts.metavar "FILE" @@ -78,9 +90,14 @@ main = do (fromString (optHost opts)) 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] listening on " <> optHost opts <> ":" <> show (optPort opts) putStrLn $ "[roux] config file: " <> optConfigFile opts + putStrLn $ "[roux] data dir: " <> dataDir -- Load config (file may be missing; LLM features disabled gracefully) configResult <- Config.loadConfig (optConfigFile opts) @@ -90,5 +107,5 @@ main = do case configResult of Left err -> putStrLn $ "[roux] warning: " <> err <> " — LLM features disabled" Right _ -> pure () - waiApp <- Roux.app (optRecipeDir opts) config + waiApp <- Roux.app (optRecipeDir opts) dataDir config Warp.runSettings settings waiApp diff --git a/docker-compose.yml b/docker-compose.yml index 481bf41..c387d12 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -5,5 +5,7 @@ services: ports: - "127.0.0.1:8080:8080" volumes: + # Cooklang source of truth, managed externally — mounted read-only. - /srv/roux/recipes:/recipes:ro + # Writable app state (the cook log SQLite DB); see --data-dir in the Dockerfile CMD. - /srv/roux/data:/data diff --git a/src/Roux/CookLog.hs b/src/Roux/CookLog.hs index df15c8d..d831651 100644 --- a/src/Roux/CookLog.hs +++ b/src/Roux/CookLog.hs @@ -2,8 +2,8 @@ {- | Cook log: track which recipes were cooked when, with optional notes. -Uses sqlite-simple for persistence. The database file lives alongside the -recipe directory as @cook-log.db@. +Uses sqlite-simple for persistence. The database file lives in the writable +data directory as @cook-log.db@ (see 'initDb'). -} module Roux.CookLog ( CookEntry (..), @@ -22,6 +22,8 @@ import Data.Time.Clock (getCurrentTime) import qualified Data.Time.Format as Time import Database.SQLite.Simple (Connection, FromRow (..), Only (..), Query) import qualified Database.SQLite.Simple as SQL +import System.Directory (createDirectoryIfMissing) +import System.FilePath (takeDirectory) -- | A single cooking log entry. data CookEntry = CookEntry @@ -70,9 +72,16 @@ instance FromJSON CookEntry where Nothing -> fail "invalid date" _ -> 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 dbPath = do + createDirectoryIfMissing True (takeDirectory dbPath) conn <- SQL.open dbPath SQL.execute_ conn createTableSQL pure conn diff --git a/src/Roux/Server.hs b/src/Roux/Server.hs index 5e9bf8e..240efa4 100644 --- a/src/Roux/Server.hs +++ b/src/Roux/Server.hs @@ -74,12 +74,15 @@ readRequestBody req = do then pure (reverse 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 recipeDir config = do +app :: FilePath -> FilePath -> RouxConfig -> IO Wai.Application +app recipeDir dataDir config = do putStrLn $ "[roux] scanning recipes from " <> recipeDir recipes <- Idx.scanRecipes recipeDir let count = length recipes @@ -101,8 +104,9 @@ app recipeDir config = do forkIO $ watchRecipes absDir state changeLog `catch` \e -> putStrLn $ "[roux] watcher thread crashed: " <> show (e :: SomeException) - -- Initialize cook log database - let dbPath = recipeDir "cook-log.db" + -- Initialize cook log database in the writable data directory (initDb + -- creates the directory and file if they are not already present). + let dbPath = dataDir "cook-log.db" db <- CookLog.initDb dbPath putStrLn $ "[roux] cook log DB: " <> dbPath pure (router absDir config state changeLog db)