diff --git a/.env.example b/.env.example index 0d93225..45be481 100644 --- a/.env.example +++ b/.env.example @@ -11,6 +11,12 @@ FOODSTER_PASSWORD=changeme # Host port to publish on. FOODSTER_PORT=8080 +# The database lives in ./data, bind-mounted into the container. These must +# match whoever owns that directory on the host, or the container cannot +# write to it. `id -u` and `id -g` will tell you. +FOODSTER_UID=1000 +FOODSTER_GID=1000 + # Used for every calendar-day calculation. Set it in development too: under # UTC the date rolls over three hours late, which is exactly when dinner # gets logged. diff --git a/.gitignore b/.gitignore index 5a51c71..cf5bd37 100644 --- a/.gitignore +++ b/.gitignore @@ -7,7 +7,8 @@ # Generated by `templ generate` during the container build. *_templ.go -# Local database +# The database and its SQLite companions. +/data/ *.db *.db-shm *.db-wal diff --git a/Makefile b/Makefile index acb1a26..b760e6d 100644 --- a/Makefile +++ b/Makefile @@ -31,10 +31,8 @@ build: generate ## Build ./foodster CGO_ENABLED=0 go build -trimpath \ -ldflags="-s -w -X main.version=dev" -o $(BIN) $(PKG) -run: generate ## Run locally on :8080 - FOODSTER_PASSWORD=$${FOODSTER_PASSWORD:-dev} \ - FOODSTER_DB=$${FOODSTER_DB:-./foodster.db} \ - go run $(PKG) +run: generate ## Run locally on :8080 (database in ./data) + FOODSTER_PASSWORD=$${FOODSTER_PASSWORD:-dev} go run $(PKG) seed: ## Import a dish bundle (SEED=seeds/testi.json) go run $(PKG) -import $(SEED) @@ -84,6 +82,7 @@ push: ## Push the newest tag and :latest release: image push ## Build, tag and push in one go up: ## Start the stack + @mkdir -p data # or the engine creates it root-owned and the app cannot write $(COMPOSE) up -d down: ## Stop the stack diff --git a/PRD.md b/PRD.md index 60796ec..5fbc821 100644 --- a/PRD.md +++ b/PRD.md @@ -342,13 +342,19 @@ on the server and run with Docker Compose. static binary; the runtime stage is `FROM scratch` holding only that binary, running as UID 65534. - **Compose**: a single service. No database container — SQLite lives at - `/data/foodster.db` on a named volume. `restart: unless-stopped`. + `/data/foodster.db`, bind-mounted from `./data` on the host rather than + kept in a named volume, so the file can be listed and copied without going + through the container engine. Backup is `cp -r data`. Because the image + runs as UID 65534, compose sets `user:` from `FOODSTER_UID`/`FOODSTER_GID` + to match whoever owns that directory. `restart: unless-stopped`. - **Configuration**, entirely through environment variables (see `.env.example`): - `FOODSTER_REPO` and `FOODSTER_TAG` — image coordinates. - `FOODSTER_PASSWORD` — the shared password. Required; the app refuses to start without it. - - `FOODSTER_DB` — database file path, default `/data/foodster.db`. + - `FOODSTER_DB` — database file path, default `./data/foodster.db`. The + directory is created on startup if missing. + - `FOODSTER_UID` / `FOODSTER_GID` — host owner of `./data`. - `TZ` — default `Europe/Helsinki`. - The registry hostname exists only in `.env`, which is gitignored, because §11 leaves open the possibility of publishing this repository. diff --git a/README.md b/README.md index 22877d3..6680bcf 100644 --- a/README.md +++ b/README.md @@ -105,7 +105,8 @@ Everything is environment variables. `.env` is gitignored; start from | Variable | Default | Purpose | |---|---|---| | `FOODSTER_PASSWORD` | *required* | Shared password. The app will not start without it. | -| `FOODSTER_DB` | `/data/foodster.db` | SQLite file path. | +| `FOODSTER_DB` | `./data/foodster.db` | SQLite file path; the directory is created if missing. | +| `FOODSTER_UID` / `FOODSTER_GID` | `1000` | Host owner of `./data`, for the bind mount. | | `TZ` | `Europe/Helsinki` | Used for every calendar-day calculation. | | `FOODSTER_REPO` | *required to build* | Image repository, no tag. | | `FOODSTER_TAG` | `latest` | Tag to run under compose. | @@ -129,8 +130,13 @@ Versions are CalVer — `vYYYYMMDD-N`, where `N` is the Nth build that day. The running version is served at `GET /healthz`, which is the one route outside authentication. -There is no database container. SQLite lives on a named volume, so a backup -is a file copy. +There is no database container. SQLite lives in `./data`, bind-mounted into +the container, so a backup is `cp -r data` and you can inspect the file with +any sqlite client without going through the engine. + +That directory must exist and be owned by the user compose runs as — `make up` +creates it, and `FOODSTER_UID`/`FOODSTER_GID` in `.env` tell the container who +that is. Get them from `id -u` and `id -g`. ## Security diff --git a/cmd/foodster/main.go b/cmd/foodster/main.go index 7735b67..1b846e6 100644 --- a/cmd/foodster/main.go +++ b/cmd/foodster/main.go @@ -18,6 +18,7 @@ import ( "net/http" "os" "os/signal" + "path/filepath" "strings" "syscall" "time" @@ -36,7 +37,10 @@ var version = "dev" const ( listenAddr = ":8080" defaultTZ = "Europe/Helsinki" - defaultDB = "./foodster.db" + // Everything SQLite writes — the database plus its -wal and -shm + // companions — lives in one directory, so a deployment mounts a single + // path and a backup copies a single directory. + defaultDB = "./data/foodster.db" // failDelay throttles password guessing. // ponytail: a fixed sleep is enough for a LAN-only app; swap in @@ -108,6 +112,12 @@ func run() error { // openDB opens the SQLite file and brings its schema up to date. func openDB(path string) (*sql.DB, error) { + if dir := filepath.Dir(path); dir != "." && dir != "" { + if err := os.MkdirAll(dir, 0o755); err != nil { + return nil, fmt.Errorf("create %s: %w", dir, err) + } + } + dsn := "file:" + path + "?_pragma=journal_mode(WAL)" + "&_pragma=foreign_keys(ON)" + diff --git a/compose.yaml b/compose.yaml index d04a75b..a5604d4 100644 --- a/compose.yaml +++ b/compose.yaml @@ -2,6 +2,11 @@ services: app: image: ${FOODSTER_REPO:?set FOODSTER_REPO in .env}:${FOODSTER_TAG:-latest} restart: unless-stopped + # A bind mount rather than a named volume: the database sits in ./data on + # the host, where it can be listed, copied and backed up without going + # through the container engine. The image runs as UID 65534, so the + # container has to be told which host user owns that directory. + user: "${FOODSTER_UID:-1000}:${FOODSTER_GID:-1000}" ports: - "${FOODSTER_PORT:-8080}:8080" environment: @@ -9,7 +14,4 @@ services: FOODSTER_DB: /data/foodster.db TZ: ${TZ:-Europe/Helsinki} volumes: - - foodster-data:/data - -volumes: - foodster-data: + - ./data:/data