Skip to content

Commit 89db3e5

Browse files
jaredLundeclaude
andauthored
feat(template): pre-bake initialized PGDATA to skip first-boot initdb (#8)
A fresh Postgres primary ran `initdb` plus the full `CREATE EXTENSION` suite in the guest before accepting connections — ~1-2s+ squarely on the cold-boot critical path. Bake an already-initialized cluster (initdb + extensions) into the rootfs at image-build time and copy it onto the fresh data volume on first boot instead. - `beyond-pg build-template <dir>` (new subcommand): runs the canonical `pg::initdb` flag set into <dir>/main, starts a build-time postgres with the same filtered `shared_preload_libraries`, and creates the same REQUIRED+OPTIONAL extension set the supervisor would (reusing its lists + `extension_installed` filter) — single source, so the baked cluster can't drift from what the runtime expects. Points at the shipped pg_hba (local peer) so the build-time psql authenticates without a password. - `maybe_initdb` materializes the template when present (atomic staging + rename, pg_wal symlink repointed to the runtime waldir, targeted fsync), else falls back to runtime initdb. PG_VERSION only appears once a complete, correct PGDATA is published, so the skip-on-PG_VERSION check stays safe and an interrupted materialize is simply redone next boot. - Per-instance state (superuser/replicator passwords, roles) stays OUT of the template; `post_start` applies it every boot, so no shared build-time secret is exposed and `CREATE EXTENSION IF NOT EXISTS` becomes a no-op on first boot. Verified: chroot build-template produces a 40M template (initdb + beyond_queue, pg_cron, pg_stat_statements, pg_trgm, hypopg, pg_repack) and a clean delta; unit tests cover materialize copy/symlink/idempotency/leftover-staging recovery. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent c2476fa commit 89db3e5

7 files changed

Lines changed: 597 additions & 11 deletions

File tree

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
#!/usr/bin/env bash
2+
set -euo pipefail
3+
4+
# Pre-bake an initialized PGDATA template into the image.
5+
#
6+
# `beyond-pg build-template` runs `initdb` (the canonical runtime flag set) and
7+
# then the full `CREATE EXTENSION` suite against a throwaway build-time postgres,
8+
# leaving a cluster at /usr/local/share/beyond-pg/pgdata-template. At first boot,
9+
# `beyond-pg-init` copies this onto the fresh data volume instead of running
10+
# initdb + CREATE EXTENSION in the guest — taking both off the cold-boot path.
11+
#
12+
# Runs LAST: it needs the postgres server + every extension `.so` installed
13+
# (02/03/04) and the beyond-pg binary in place (06). Building on the cleaned
14+
# image (post-09) mirrors the runtime environment exactly (same trimmed locales,
15+
# so the en_US.UTF-8 initdb locale resolves identically).
16+
#
17+
# Per-instance state is NOT baked in (superuser/replicator passwords, roles) —
18+
# the supervisor's post_start applies those on every boot.
19+
20+
TEMPLATE_DIR="/usr/local/share/beyond-pg/pgdata-template"
21+
22+
echo "==> Building pre-initialized PGDATA template at ${TEMPLATE_DIR}..."
23+
/usr/local/bin/beyond-pg build-template "${TEMPLATE_DIR}"
24+
25+
# Sanity: the template must carry a complete cluster (PG_VERSION present) or the
26+
# first-boot materialize would silently fall back to runtime initdb.
27+
test -f "${TEMPLATE_DIR}/main/PG_VERSION" \
28+
|| { echo "FATAL: template missing PG_VERSION" >&2; exit 1; }
29+
30+
echo "==> 10-pgdata-template done ($(du -sh "${TEMPLATE_DIR}" | cut -f1))"

‎src/boot.rs‎

Lines changed: 260 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ use crate::mmds::{MmdsConfig, MmdsError, PgTier};
1818
use crate::pg::{self, PGDATA};
1919

2020
const PG_WAL_LINK: &str = "/var/lib/postgresql/18/main/pg_wal";
21-
const PG_WAL_TARGET: &str = "/var/lib/postgresql/18/wal";
21+
const PG_WAL_TARGET: &str = crate::pg::PG_WALDIR;
2222
const HOOKS_PRE_START: &str = "/etc/postgresql/18/hooks/pre-start.d";
2323

2424
#[derive(Debug, thiserror::Error)]
@@ -216,10 +216,146 @@ async fn maybe_initdb(cfg: &MmdsConfig) -> Result<(), BootError> {
216216

217217
chown_data_tree();
218218

219+
// Fast path: a pre-baked PGDATA template shipped in the rootfs (image build
220+
// ran `beyond-pg build-template`). Copy it onto the fresh volume instead of
221+
// running initdb (+ first-boot CREATE EXTENSION) in the guest — both come off
222+
// the cold-boot critical path. The supervisor's `post_start` still resets the
223+
// per-instance superuser password and runs idempotent `CREATE EXTENSION IF NOT
224+
// EXISTS` (no-ops against the baked extensions), so the result is identical to
225+
// a runtime initdb.
226+
if template_available() {
227+
info!("materializing PGDATA from template, skipping initdb");
228+
materialize_template()?;
229+
return Ok(());
230+
}
231+
219232
run_initdb(&cfg.postgres_password).await?;
220233
Ok(())
221234
}
222235

236+
/// True iff a complete pre-baked PGDATA template is present in the rootfs.
237+
fn template_available() -> bool {
238+
template_available_in(crate::template::TEMPLATE_DIR)
239+
}
240+
241+
fn template_available_in(template_dir: &str) -> bool {
242+
Path::new(&format!("{template_dir}/main/PG_VERSION")).exists()
243+
}
244+
245+
/// Copy the baked PGDATA template onto the fresh data volume, then chown it.
246+
fn materialize_template() -> Result<(), BootError> {
247+
materialize_template_into(crate::template::TEMPLATE_DIR, PGDATA, PG_WAL_TARGET)?;
248+
chown_data_tree();
249+
info!("materialized PGDATA from template");
250+
Ok(())
251+
}
252+
253+
/// Copy the template at `template_dir` (`main/` + `wal/`) onto `pgdata` + `wal_target`.
254+
///
255+
/// Atomic + idempotent (per CLAUDE.md): the template is copied into staging dirs
256+
/// alongside the destinations, the `pg_wal` symlink is repointed to `wal_target`,
257+
/// the staged data is flushed, and only then are the staging dirs renamed into
258+
/// place. `pgdata/PG_VERSION` therefore becomes visible only once a complete,
259+
/// correct PGDATA is published — so `maybe_initdb`'s skip-on-`PG_VERSION` is safe
260+
/// and an interrupted materialize is simply redone on the next boot.
261+
fn materialize_template_into(
262+
template_dir: &str,
263+
pgdata: &str,
264+
wal_target: &str,
265+
) -> Result<(), BootError> {
266+
let tmpl_main = format!("{template_dir}/main");
267+
let tmpl_wal = format!("{template_dir}/wal");
268+
let main_staging = format!("{pgdata}.staging");
269+
let wal_staging = format!("{wal_target}.staging");
270+
271+
// Idempotent clean slate. `pgdata` may exist empty (partial-PGDATA cleanup
272+
// recreated it above); a prior interrupted materialize may have left a wal
273+
// dir or *.staging dirs.
274+
for p in [&main_staging, &wal_staging, &pgdata.to_string(), &wal_target.to_string()] {
275+
rm_rf(p)?;
276+
}
277+
278+
// `cp -a` preserves the pg_wal symlink, file modes, and postgres ownership.
279+
cp_a(&tmpl_wal, &wal_staging)?;
280+
cp_a(&tmpl_main, &main_staging)?;
281+
282+
// Repoint the baked `pg_wal` symlink (template-relative) to the runtime WAL
283+
// dir BEFORE publishing, so a crash after the rename never leaves a wrong
284+
// target that `verify_wal_symlink` would reject.
285+
let staged_link = format!("{main_staging}/pg_wal");
286+
match std::fs::symlink_metadata(&staged_link) {
287+
Ok(_) => std::fs::remove_file(&staged_link)?,
288+
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
289+
Err(e) => return Err(BootError::Io(e)),
290+
}
291+
std::os::unix::fs::symlink(wal_target, &staged_link)?;
292+
293+
// Flush the staged tree (file contents + dir entries) so the data is durable
294+
// BEFORE it is published. Then rename atomically — WAL first, then PGDATA, so
295+
// `PG_VERSION` only appears once `main` is renamed, by which point both the
296+
// symlink and the data are correct and durable. Finally fsync the parent dir
297+
// to make the renames themselves durable. Targeted fsync (not a global
298+
// sync(2)) keeps this fast and non-blocking under concurrent I/O.
299+
fsync_tree(Path::new(&wal_staging))?;
300+
fsync_tree(Path::new(&main_staging))?;
301+
std::fs::rename(&wal_staging, wal_target)?;
302+
std::fs::rename(&main_staging, pgdata)?;
303+
if let Some(parent) = Path::new(pgdata).parent() {
304+
fsync_dir(parent)?;
305+
}
306+
307+
Ok(())
308+
}
309+
310+
/// Recursively fsync every file and directory under `path` (depth-first, so each
311+
/// directory is fsynced after its entries). Symlinks are not followed — the
312+
/// containing directory's fsync makes the symlink entry durable.
313+
fn fsync_tree(path: &Path) -> Result<(), BootError> {
314+
let meta = std::fs::symlink_metadata(path)?;
315+
if meta.is_dir() {
316+
for entry in std::fs::read_dir(path)? {
317+
fsync_tree(&entry?.path())?;
318+
}
319+
fsync_dir(path)?;
320+
} else if meta.is_file() {
321+
std::fs::File::open(path)?.sync_all()?;
322+
}
323+
Ok(())
324+
}
325+
326+
/// fsync a directory (durably commit its entries). On Linux a directory can be
327+
/// opened read-only and `fsync`'d via `sync_all`.
328+
fn fsync_dir(path: &Path) -> Result<(), BootError> {
329+
std::fs::File::open(path)?.sync_all()?;
330+
Ok(())
331+
}
332+
333+
/// `rm -rf` a path (file, symlink, or directory). Idempotent — a missing path is
334+
/// not an error.
335+
fn rm_rf(path: &str) -> Result<(), BootError> {
336+
match std::fs::symlink_metadata(path) {
337+
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
338+
Err(e) => Err(BootError::Io(e)),
339+
Ok(meta) if meta.is_dir() => Ok(std::fs::remove_dir_all(path)?),
340+
Ok(_) => Ok(std::fs::remove_file(path)?),
341+
}
342+
}
343+
344+
/// `cp -a src dst` — recursive copy preserving symlinks, modes, and ownership.
345+
fn cp_a(src: &str, dst: &str) -> Result<(), BootError> {
346+
let status = std::process::Command::new("cp")
347+
.args(["-a", src, dst])
348+
.status()?;
349+
if status.success() {
350+
Ok(())
351+
} else {
352+
Err(BootError::Io(std::io::Error::other(format!(
353+
"cp -a {src} {dst} failed: {status}"
354+
))))
355+
}
356+
}
357+
358+
223359
/// chown `/var/lib/postgresql` → postgres recursively. A fresh durable volume
224360
/// mounts root-owned over the image dir, and a root-run `pg_basebackup` (replica
225361
/// seeding) writes a root-owned PGDATA — either way postgres dies at startup with
@@ -258,7 +394,7 @@ async fn run_initdb(password: &str) -> Result<(), BootError> {
258394
.to_str()
259395
.ok_or_else(|| BootError::Io(std::io::Error::other("tempfile path is not UTF-8")))?;
260396
info!("running initdb");
261-
pg::initdb(PGDATA, path_str)
397+
pg::initdb(PGDATA, PG_WAL_TARGET, path_str)
262398
.await
263399
.map_err(|e| BootError::InitDb(e.to_string()))
264400
// pwfile is dropped here — tempfile removes it from disk
@@ -465,6 +601,128 @@ mod tests {
465601
);
466602
}
467603

604+
// -----------------------------------------------------------------------
605+
// PGDATA template materialize unit tests
606+
// -----------------------------------------------------------------------
607+
608+
/// Build a minimal fake template at `dir`: `main/` with PG_VERSION + a
609+
/// template-relative `pg_wal` symlink, and `wal/` with a segment file.
610+
fn make_fake_template(dir: &Path) {
611+
let tmain = dir.join("main");
612+
let twal = dir.join("wal");
613+
std::fs::create_dir_all(&tmain).unwrap();
614+
std::fs::create_dir_all(&twal).unwrap();
615+
std::fs::write(tmain.join("PG_VERSION"), "18\n").unwrap();
616+
std::fs::write(tmain.join("postgresql.conf"), "# initdb default\n").unwrap();
617+
std::fs::write(twal.join("000000010000000000000001"), b"wal-seg").unwrap();
618+
// Baked symlink points template-relative — WRONG for runtime; the
619+
// materialize must repoint it to the real wal target.
620+
std::os::unix::fs::symlink(&twal, tmain.join("pg_wal")).unwrap();
621+
}
622+
623+
#[test]
624+
fn template_available_detects_pg_version() {
625+
let tmp = tempfile::tempdir().unwrap();
626+
let dir = tmp.path().join("template");
627+
assert!(!template_available_in(dir.to_str().unwrap()));
628+
std::fs::create_dir_all(dir.join("main")).unwrap();
629+
assert!(
630+
!template_available_in(dir.to_str().unwrap()),
631+
"empty main/ is not a usable template"
632+
);
633+
std::fs::write(dir.join("main/PG_VERSION"), "18").unwrap();
634+
assert!(template_available_in(dir.to_str().unwrap()));
635+
}
636+
637+
#[test]
638+
fn materialize_copies_template_and_repoints_wal_symlink() {
639+
let tmp = tempfile::tempdir().unwrap();
640+
let template = tmp.path().join("template");
641+
make_fake_template(&template);
642+
643+
let data = tmp.path().join("data");
644+
std::fs::create_dir_all(&data).unwrap();
645+
let pgdata = data.join("main");
646+
let wal_target = data.join("wal");
647+
648+
materialize_template_into(
649+
template.to_str().unwrap(),
650+
pgdata.to_str().unwrap(),
651+
wal_target.to_str().unwrap(),
652+
)
653+
.unwrap();
654+
655+
// PGDATA + WAL contents present.
656+
assert!(pgdata.join("PG_VERSION").exists());
657+
assert!(pgdata.join("postgresql.conf").exists());
658+
assert!(wal_target.join("000000010000000000000001").exists());
659+
// pg_wal repointed to the absolute runtime WAL target (what
660+
// verify_wal_symlink expects), not the template-relative path.
661+
assert_eq!(
662+
std::fs::read_link(pgdata.join("pg_wal")).unwrap(),
663+
wal_target
664+
);
665+
// No staging dirs left behind.
666+
assert!(!data.join("main.staging").exists());
667+
assert!(!data.join("wal.staging").exists());
668+
}
669+
670+
#[test]
671+
fn materialize_is_idempotent() {
672+
let tmp = tempfile::tempdir().unwrap();
673+
let template = tmp.path().join("template");
674+
make_fake_template(&template);
675+
let data = tmp.path().join("data");
676+
std::fs::create_dir_all(&data).unwrap();
677+
let pgdata = data.join("main");
678+
let wal_target = data.join("wal");
679+
680+
let run = || {
681+
materialize_template_into(
682+
template.to_str().unwrap(),
683+
pgdata.to_str().unwrap(),
684+
wal_target.to_str().unwrap(),
685+
)
686+
};
687+
run().unwrap();
688+
// A second materialize over a published PGDATA succeeds (clean-slate
689+
// removes the prior copy) and yields the same correct layout.
690+
run().unwrap();
691+
assert!(pgdata.join("PG_VERSION").exists());
692+
assert_eq!(
693+
std::fs::read_link(pgdata.join("pg_wal")).unwrap(),
694+
wal_target
695+
);
696+
}
697+
698+
#[test]
699+
fn materialize_recovers_from_leftover_staging() {
700+
let tmp = tempfile::tempdir().unwrap();
701+
let template = tmp.path().join("template");
702+
make_fake_template(&template);
703+
let data = tmp.path().join("data");
704+
std::fs::create_dir_all(&data).unwrap();
705+
let pgdata = data.join("main");
706+
let wal_target = data.join("wal");
707+
708+
// Simulate an interrupted prior run: stale staging dirs + an empty PGDATA
709+
// (as partial-PGDATA cleanup would have recreated).
710+
std::fs::create_dir_all(data.join("main.staging")).unwrap();
711+
std::fs::write(data.join("main.staging/garbage"), b"x").unwrap();
712+
std::fs::create_dir_all(data.join("wal.staging")).unwrap();
713+
std::fs::create_dir_all(&pgdata).unwrap();
714+
715+
materialize_template_into(
716+
template.to_str().unwrap(),
717+
pgdata.to_str().unwrap(),
718+
wal_target.to_str().unwrap(),
719+
)
720+
.unwrap();
721+
722+
assert!(pgdata.join("PG_VERSION").exists());
723+
assert!(!pgdata.join("garbage").exists(), "stale staging must not leak in");
724+
}
725+
468726
// -----------------------------------------------------------------------
469727
// run_hook_scripts unit tests
470728
// -----------------------------------------------------------------------

‎src/config.rs‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,26 @@ pub fn beyond_conf() -> String {
4545
filter_shared_preload_libraries(BEYOND_CONF, PKGLIBDIR)
4646
}
4747

48+
/// The `shared_preload_libraries` value (bare comma list, no quotes) from
49+
/// `00-beyond.conf`, filtered to the libraries actually installed. The template
50+
/// builder passes this to a build-time postgres (`-c shared_preload_libraries=…`)
51+
/// so `CREATE EXTENSION pg_cron` / `beyond_queue` — which refuse to load unless
52+
/// preloaded — succeed against the same set the runtime preloads. Empty string
53+
/// if `00-beyond.conf` lists no installed preload libraries.
54+
pub fn preload_libraries() -> String {
55+
for line in BEYOND_CONF.lines() {
56+
if let Some(rewritten) = filter_preload_line(line, "shared_preload_libraries", PKGLIBDIR) {
57+
// `rewritten` is `shared_preload_libraries = '...'` — pull out the
58+
// single-quoted value.
59+
return rewritten
60+
.split_once('=')
61+
.map(|(_, v)| v.trim().trim_matches('\'').to_string())
62+
.unwrap_or_default();
63+
}
64+
}
65+
String::new()
66+
}
67+
4868
/// Returns true iff `{pkglibdir}/{lib}.so` exists. Core-postgres libraries
4969
/// (`pg_stat_statements`, `auto_explain`) are present in any standard install.
5070
fn library_installed(pkglibdir: &str, lib: &str) -> bool {

0 commit comments

Comments
 (0)