Phase 15: containerize Petal for the parodia.dev VPS
Deploy plumbing so Petal can run on the public VPS behind the Traefik already on that box, with vLLM reached over headscale. - Dockerfile: node build -> go build -> alpine runtime. CGO stays off (modernc SQLite is pure Go), so the runtime layer exists only for ffmpeg (read-aloud transcodes Piper's WAV) and tzdata (the companion's bedtime nag and night mode read the local clock). Runs as uid 10001 with /data as the single writable mount. - docker-compose.yml: Traefik labels following this host's convention (external `traefik` network, `web-secure` entrypoint, `default` cert resolver). Petal publishes no host port. ./data is a bind mount, not a named volume, so the nightly backup and a restore are reachable from the host. - Piper runs as two sibling containers rather than host systemd units. The plan assumed Piper was already installed on the VPS; it is not, the host has no lingering user session to keep user units alive, and containers keep the TTS ports on an internal network unreachable from anywhere but Petal. One image, voice chosen per service, model cached in a shared volume -- so the pt-PT voice is a new service, not a new image. - db.Backup + a `-backup` flag: VACUUM INTO, not a file copy. Petal runs in WAL mode, so the newest committed pages may live in petal.db-wal; copying the three files separately can capture a torn mid-checkpoint state. VACUUM INTO reads one coherent snapshot without taking a write lock, and emits a single file with no -wal/-shm companions. Refuses an existing destination so a failed run can't destroy the last good backup. - deploy/backup-petal.sh: nightly snapshot, compress, push to millenia over headscale with a post-transfer size check, prune both sides. - deploy/petal.env.example: LLM_TIMEOUT raised 30s -> 90s for the WAN+VPN round trip, since the voice and collocation passes send a whole document and the timeout is a hard deadline on Complete.
This commit is contained in:
@@ -0,0 +1,79 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// Backup writes a consistent copy of the database at srcPath to destPath using
|
||||
// SQLite's `VACUUM INTO`.
|
||||
//
|
||||
// Why not copy the file: Petal runs in WAL mode, so at any instant the newest
|
||||
// committed pages may live in petal.db-wal rather than petal.db. Copying the
|
||||
// three files separately can capture them mid-checkpoint and produce a backup
|
||||
// that is subtly torn. `VACUUM INTO` runs inside a read transaction, so it sees
|
||||
// one coherent snapshot including the WAL, and emits a single defragmented file
|
||||
// with no -wal/-shm companions — exactly what you want to ship off-box.
|
||||
//
|
||||
// It takes no write lock, so this is safe to run against the live database
|
||||
// while someone is writing.
|
||||
//
|
||||
// destPath must not already exist: SQLite refuses to overwrite, which keeps a
|
||||
// failed run from destroying the previous good backup.
|
||||
func Backup(srcPath, destPath string) error {
|
||||
if _, err := os.Stat(srcPath); err != nil {
|
||||
return fmt.Errorf("source database: %w", err)
|
||||
}
|
||||
if _, err := os.Stat(destPath); err == nil {
|
||||
return fmt.Errorf("destination %s already exists", destPath)
|
||||
}
|
||||
if dir := filepath.Dir(destPath); dir != "" && dir != "." {
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return fmt.Errorf("create backup dir: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Opened directly rather than through Open: a backup must never migrate or
|
||||
// seed the database it is copying.
|
||||
conn, err := sql.Open("sqlite", dsn(srcPath))
|
||||
if err != nil {
|
||||
return fmt.Errorf("open source: %w", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
conn.SetMaxOpenConns(1)
|
||||
|
||||
if err := conn.Ping(); err != nil {
|
||||
return fmt.Errorf("ping source: %w", err)
|
||||
}
|
||||
|
||||
// The path is interpolated because VACUUM INTO takes a literal, not a bound
|
||||
// parameter. Quotes are doubled so a path containing one can't break out.
|
||||
quoted := "'" + escapeSQLiteString(destPath) + "'"
|
||||
if _, err := conn.Exec("VACUUM INTO " + quoted); err != nil {
|
||||
return fmt.Errorf("vacuum into %s: %w", destPath, err)
|
||||
}
|
||||
|
||||
// A zero-byte result would mean the vacuum silently produced nothing; catch
|
||||
// it here rather than discovering it during a restore.
|
||||
info, err := os.Stat(destPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("stat backup: %w", err)
|
||||
}
|
||||
if info.Size() == 0 {
|
||||
return fmt.Errorf("backup %s is empty", destPath)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func escapeSQLiteString(s string) string {
|
||||
out := make([]byte, 0, len(s))
|
||||
for i := 0; i < len(s); i++ {
|
||||
if s[i] == '\'' {
|
||||
out = append(out, '\'')
|
||||
}
|
||||
out = append(out, s[i])
|
||||
}
|
||||
return string(out)
|
||||
}
|
||||
Reference in New Issue
Block a user