diff --git a/.kiro/specs/config-upgrade-snapshot/tasks.md b/.kiro/specs/config-upgrade-snapshot/tasks.md index b14d68d0..40b14972 100644 --- a/.kiro/specs/config-upgrade-snapshot/tasks.md +++ b/.kiro/specs/config-upgrade-snapshot/tasks.md @@ -18,7 +18,7 @@ - Observable: a checkpoint's queue section decodes via the new message type, and a checkpoint written under the prior raw layout is rejected fail-loud rather than silently reinterpreted - _Requirements: 1.9, 5.7_ -- [ ] 2.3 Embed the active configuration bytes into every checkpoint +- [x] 2.3 Embed the active configuration bytes into every checkpoint - Read the locally-held active configuration bytes directly from where they are already durably saved on disk, and append them as a new checkpoint section after the host frames - Ensure this section is populated on every checkpoint (not only a marker-triggered one), so a member joining or catching up after the marker-triggered snapshot has rotated away still receives configuration and state together - Keep configuration bytes out of WAL entry payloads entirely — only the checkpoint file carries them @@ -149,6 +149,8 @@ ## Implementation Notes +- **Task 2.3 → task 6.1 heads-up:** `read_active_config_bytes` (persist.c) is the fully-inlined (no config_repl.c call) pattern for reading live config bytes off disk fresh each time — task 6.1's activation-fence verify function needs the identical "always re-read live bytes, never trust a cached/embedded copy" discipline (design.md is explicit about this). Reuse/mirror `read_active_config_bytes` rather than re-deriving the config_repl.c-avoidance reasoning from scratch. + - **Task 2.2 → task 4 MUST re-enable:** `test_snapent_load_snapshot_restores_entities_before_advancing_position` (test_persist_snapshot_entities.c) is currently `TEST_IGNORE_MESSAGE`'d — its `m_queues=2` fixture round-trips through `persist_load_snapshot`, whose queue decode still expects the pre-2.2 raw layout. Task 4 (queue-frame decode migration) MUST remove the ignore and confirm this test passes for real once `load_snapshot`'s queue decode reads `QueueAdminState` capnp. Do not leave it ignored after task 4 lands. - `test_snapent_checkpoint_queue_status_roundtrip`'s comments (~line 1019) are stale (still reference "format_version=1" / "task 3.2 not yet implemented" from an earlier spec pass) — harmless, not a task 2.2 defect, but worth cleaning up whenever that test is next touched. diff --git a/src/controller/persist.c b/src/controller/persist.c index c2b987c8..6d117999 100644 --- a/src/controller/persist.c +++ b/src/controller/persist.c @@ -2063,6 +2063,143 @@ static int collect_host_frames(persist_t* p, xlist_t* frames) return rc; } +/* Bound on the config bytes this file will embed in a checkpoint -- mirrors + * config_repl.h's CONF_MAX_SIZE (1 MiB) exactly. Duplicated as its own local + * constant rather than including config_repl.h, per the zero-config_repl.c- + * dependency decision documented on read_active_config_bytes() below. */ +#define PERSIST_CONFIG_BYTES_MAX_SIZE (1u * 1024u * 1024u) + +/* read_active_config_bytes (config-upgrade-snapshot task 2.3, Req 2.1, 2.3, + * 2.4, 5.1): read the locally-held active configuration's raw bytes straight + * off disk, for embedding in every checkpoint's trailing config-bytes + * section. + * + * DELIBERATE DESIGN CHOICE -- read this before changing the call site below: + * this function duplicates config_repl.c's `conf_read_current_filename` + + * `conf_read_raw` path-resolution-and-read logic INLINE, rather than calling + * those functions (config_repl.h is not included by this file at all). This + * mirrors scan_latest_committed_config_ref()'s own precedent just above, + * which independently decodes WAL_CONFIG_MARKER payloads inline "so persist + * stays free of a config_repl layering dependency" instead of adding a + * persist.c -> config_repl.c function-call edge -- and design.md's + * "Snapshot Entity/Config Payload" + File Structure Plan sections direct the + * SAME treatment here verbatim ("no new config_repl.c function is added... + * this deliberately mirrors scan_latest_committed_config_ref's own existing + * precedent of avoiding a persist.c -> config_repl.c function-call + * dependency, rather than introducing the layering violation a new accessor + * would create"). + * + * A future reader who spots ha.c's two `extern int conf_read_raw(...)` call + * sites (config push to standbys) and wonders why this file doesn't do the + * same: ha.c is the intentional CONSUMER of config-bytes sourcing + * (design.md's Architecture boundary map: "config replication (config_repl.c) + * owns config-bytes sourcing"), so a ha.c -> config_repl.c call is expected, + * in-boundary integration. persist.c owns materialization/on-disk-format and + * is deliberately kept free of any dependency on config_repl.c's domain -- + * the same boundary this file already draws for the WAL-marker case above. + * This is a considered choice, not an oversight: duplicating this ~30-line + * read-with-validation routine once was judged cheaper than adding this + * file's first cross-domain coupling edge into config_repl.c. + * + * Returns 0 with *out_bytes (caller must free()) / *out_len set on success. + * Returns -ENOENT when no local config bytes exist yet: no cluster/myconf + * back-pointer (e.g. a bare unit-test persist_t), no workdir/name configured, + * no conf/current pointer file yet (this node has not received its first + * config push), or the pointer names a versioned file that is not present. + * This is a BENIGN, expected state (early boot / not-yet-provisioned), never + * corruption -- callers must treat -ENOENT as "no config yet", not failure. + * Any OTHER negative errno (-EINVAL malformed pointer/oversized file, -EIO + * read failure, -ENOMEM, -EFBIG) indicates genuine on-disk corruption or a + * real I/O failure and must propagate as a hard failure, never be silently + * downgraded to an empty section. */ +static int read_active_config_bytes(persist_t* p, uint8_t** out_bytes, + size_t* out_len) +{ + cluster_t* c = p->cluster; + if (c == NULL || c->myconf == NULL || + c->myconf->workdir == NULL || c->myconf->name == NULL) + return -ENOENT; + + /* effective_dir = workdir/name -- must match conf_save_config's own + * path construction (config_repl.c:781) exactly. */ + char conf_dir[4096]; + snprintf(conf_dir, sizeof(conf_dir), "%s/%s", c->myconf->workdir, + c->myconf->name); + + /* Step 1: resolve the "current" versioned filename -- mirrors + * conf_read_current_filename's validation exactly (non-empty, no path + * separator, "cluster.bin." prefix + 12 lowercase hex chars) so a + * corrupted pointer file is caught here just as loudly as it would be + * there. */ + char head_path[4160]; + snprintf(head_path, sizeof(head_path), "%s/conf/current", conf_dir); + + FILE* hf = fopen(head_path, "r"); + if (hf == NULL) + return -ENOENT; /* no config pushed to this node yet */ + + struct stat hst; + if (fstat(fileno(hf), &hst) == 0 && hst.st_size == 0) { + fclose(hf); + return -ENOENT; /* interrupted-write pointer file -- treat as absent */ + } + + char fname[256]; + if (fgets(fname, (int)sizeof(fname), hf) == NULL) { + fclose(hf); + return -EIO; + } + fclose(hf); + fname[strcspn(fname, "\n")] = '\0'; + + if (strchr(fname, '/') != NULL) + return -EINVAL; /* corrupted pointer file -- embedded path separator */ + if (strncmp(fname, "cluster.bin.", 12) != 0) + return -EINVAL; + const char* hex = fname + 12; + if (strlen(hex) != 12 || strspn(hex, "0123456789abcdef") != 12) + return -EINVAL; + + /* Step 2: read the versioned config file's raw bytes. */ + char cfg_path[4416]; + snprintf(cfg_path, sizeof(cfg_path), "%s/conf/%s", conf_dir, fname); + + FILE* cf = fopen(cfg_path, "rb"); + if (cf == NULL) + return -ENOENT; /* pointer references a file that is not there -- + * treat as not-yet-ready rather than corruption */ + + if (fseek(cf, 0, SEEK_END) != 0) { fclose(cf); return -EIO; } + long sz = ftell(cf); + if (sz < 0) { fclose(cf); return -EIO; } + if (fseek(cf, 0, SEEK_SET) != 0) { fclose(cf); return -EIO; } + + if (sz == 0) { + fclose(cf); + return -EIO; /* zero-byte versioned config file: genuine corruption */ + } + if ((size_t)sz > PERSIST_CONFIG_BYTES_MAX_SIZE) { + fclose(cf); + return -EFBIG; + } + + uint8_t* buf = (uint8_t*)malloc((size_t)sz); + if (buf == NULL) { + fclose(cf); + return -ENOMEM; + } + if (fread(buf, 1, (size_t)sz, cf) != (size_t)sz) { + free(buf); + fclose(cf); + return -EIO; + } + fclose(cf); + + *out_bytes = buf; + *out_len = (size_t)sz; + return 0; +} + /* Write an entity-bearing checkpoint: legacy header (unchanged) + entity * payload (ha-entity-snapshots task 2.2/3.2, Req 1.1-1.4, 3.1-3.3). Any * failure at any step -- collect, encode, write, sync -- leaves NO file at @@ -2197,6 +2334,56 @@ static int write_snapshot(persist_t* p) } } + /* Config-bytes section (config-upgrade-snapshot task 2.3, Req 2.1, 2.3, + * 2.4, 5.1): raw active-configuration bytes, appended LAST. This is a + * single flat length-prefixed blob for the whole file -- NOT a + * [count][frame...] shape like the job/queue/host sections above, since + * there is exactly one active configuration, never a set of them. + * Populated on EVERY checkpoint (marker-triggered or periodic alike, + * never gated on which triggered this call) so a member joining or + * catching up after the marker-triggered upgrade snapshot has rotated + * away still gets configuration and materialized state together from + * whichever checkpoint it installs (Req 2.4). A pure function of + * whatever bytes currently sit at the resolved config path -- no + * timestamp, no ordering dependence -- so reading the same unchanged + * file twice yields byte-identical output (Req 5.1 determinism). */ + uint8_t* cfg_bytes = NULL; + size_t cfg_len = 0; + if (rc == 0) { + int cfg_rc = read_active_config_bytes(p, &cfg_bytes, &cfg_len); + if (cfg_rc == -ENOENT) { + /* No local config bytes yet -- no cluster/myconf back-pointer + * (bare unit-test persist_t) or this node has not received its + * first config push. Mirrors the "no live cluster back-pointer" + * empty-is-not-failure discipline collect_job/queue/host_frames + * already apply above: write an explicit zero-length section + * rather than failing the whole checkpoint over a benign, + * expected early-boot / not-yet-provisioned state. */ + cfg_bytes = NULL; + cfg_len = 0; + } else if (cfg_rc != 0) { + /* Any other error means genuinely corrupt/unreadable on-disk + * config state, not benign absence -- fail the checkpoint like + * any other collect/restore failure (design.md Error Handling: + * "Host placeholder creation failure ... treated as any other + * collect/restore failure today -- discard partial state, fail + * the checkpoint/restore attempt, retriable on the next + * cycle"). */ + rc = cfg_rc; + } + } + + if (rc == 0) { + uint32_t cfg_bytes_len = (uint32_t)cfg_len; + if (write(fd, &cfg_bytes_len, sizeof(cfg_bytes_len)) != + (ssize_t)sizeof(cfg_bytes_len)) + rc = -EIO; + } + if (rc == 0 && cfg_len > 0 && + write(fd, cfg_bytes, cfg_len) != (ssize_t)cfg_len) + rc = -EIO; + free(cfg_bytes); + if (rc == 0 && fdatasync(fd) != 0) rc = -errno; diff --git a/src/controller/testsuite/test_persist_snapshot_entities.c b/src/controller/testsuite/test_persist_snapshot_entities.c index 0814014a..19336c38 100644 --- a/src/controller/testsuite/test_persist_snapshot_entities.c +++ b/src/controller/testsuite/test_persist_snapshot_entities.c @@ -52,6 +52,7 @@ #include #include +#include #include #include #include @@ -66,8 +67,10 @@ #include #include "cluster.h" +#include "config_repl.h" #include "controller.capnp.h" #include "jobj.h" +#include "myconf.h" #include "qobj.h" #include "wobj.h" #include "xce/cluster_def.h" @@ -174,6 +177,11 @@ static void snapent_job_cmdline(int i, char* buf, size_t buf_sz) typedef struct { cluster_t* c; char tmpdir[256]; + /* Only populated/wired (f->c->myconf = &f->myconf) by tests that call + * snapent_wire_myconf -- task 2.3's config-bytes-section tests. Every + * other existing test leaves f->c->myconf NULL, exactly as before this + * field was added (zero footprint on already-passing tests). */ + myconf_t myconf; } snapent_fixture_t; /* jobj.c's jobsub/startinfo tables are process-global and idempotently @@ -338,6 +346,29 @@ static snapent_fixture_t* snapent_make(int n_jobs, int m_queues) return f; } +/* Wire a real myconf_t onto the fixture's cluster (config-upgrade-snapshot + * task 2.3): f->myconf.workdir/name are set to the SAME (f->tmpdir, + * "snapent") pair persist_set_workdir already used above -- exactly mirroring + * production wiring, where a single controller instance's persist_set_workdir + * and c->myconf->workdir/name are literally the same values (cluster.c:1408- + * 1441). effective_dir = workdir/name is therefore shared between persist's + * OWN data/{wal,snap} subtree and myconf's conf/ subtree with no collision + * (different subdirectories under the same instance root), the same layout a + * real controller process has on disk. + * + * Does NOT write any config bytes itself -- callers that need real bytes on + * disk call conf_save_config() afterward (config_repl.h); a caller that + * wires myconf but never saves anything is deliberately exercising the + * "live cluster, no config pushed yet" -ENOENT path in + * read_active_config_bytes (persist.c). */ +static void snapent_wire_myconf(snapent_fixture_t* f) +{ + f->myconf.workdir = f->tmpdir; + f->myconf.name = (char*)"snapent"; + f->myconf.conf_keep_n = 2; + f->c->myconf = &f->myconf; +} + static void snapent_teardown(snapent_fixture_t* f) { if (f == NULL) @@ -352,6 +383,11 @@ static void snapent_teardown(snapent_fixture_t* f) if (f->c->workermgr) dereference(f->c->workermgr); xce_hook_fini(&f->c->hooksys); + /* free(NULL) is safe -- conffile is only heap-allocated (via + * conf_save_config's asprintf) when a test actually wired+saved a real + * config; every other test leaves it at its calloc'd NULL. */ + free(f->myconf.conffile); + free(f->c); free(f); } @@ -1217,6 +1253,106 @@ static int snapent_read_checkpoint_hosts(const char* path, return n; } +/* Reads the trailing config-bytes section a checkpoint file carries + * (config-upgrade-snapshot task 2.3, Req 2.1, 2.3, 2.4, 5.1): skips past the + * legacy header + format_version + job/queue/host sections exactly like + * snapent_read_checkpoint_hosts above, then reads the final + * [config_bytes_len][raw config bytes] blob -- the single flat section that + * comes after every other section, never a [count][frame...] shape. + * + * On success returns 0 with *out_bytes (caller must free(), NULL when + * out_len is 0) / *out_len set. Returns -1 on any parse/read failure + * (malformed header, truncated section, etc.) -- mirrors every other + * snapent_read_checkpoint_* reader's -1-on-failure convention in this file. */ +static int snapent_read_checkpoint_config_bytes(const char* path, + uint8_t** out_bytes, + size_t* out_len) +{ + int fd = open(path, O_RDONLY); + if (fd < 0) + return -1; + + char header[SNAPENT_LEGACY_HEADER_BYTES]; + if (read(fd, header, sizeof(header)) != (ssize_t)sizeof(header) || + memcmp(header, "XCESNAP\0", 8) != 0) { + close(fd); + return -1; + } + + uint32_t format_version = 0; + if (read(fd, &format_version, sizeof(format_version)) != + (ssize_t)sizeof(format_version) || + format_version != SNAPENT_TEST_FORMAT_VERSION) { + close(fd); + return -1; + } + + /* Skip job, queue, and host sections -- all three share the same + * [count][payload_len][bytes]* shape by this point (task 2.2 migrated + * queue frames to capnp, matching job/host). */ + for (int section = 0; section < 3; section++) { + uint32_t count = 0; + if (read(fd, &count, sizeof(count)) != (ssize_t)sizeof(count)) { + close(fd); + return -1; + } + for (uint32_t i = 0; i < count; i++) { + uint32_t payload_len = 0; + if (read(fd, &payload_len, sizeof(payload_len)) != + (ssize_t)sizeof(payload_len)) { + close(fd); + return -1; + } + if (payload_len == 0 || payload_len > SNAPENT_MAX_DECODE_PAYLOAD) { + close(fd); + return -1; + } + uint8_t* discard = (uint8_t*)malloc(payload_len); + if (discard == NULL) { + close(fd); + return -1; + } + ssize_t got = read(fd, discard, payload_len); + free(discard); + if (got != (ssize_t)payload_len) { + close(fd); + return -1; + } + } + } + + /* Config-bytes section: config_bytes_len + raw bytes (no per-item + * framing -- exactly one blob for the whole file). */ + uint32_t cfg_len = 0; + if (read(fd, &cfg_len, sizeof(cfg_len)) != (ssize_t)sizeof(cfg_len)) { + close(fd); + return -1; + } + if (cfg_len > SNAPENT_MAX_DECODE_PAYLOAD) { + close(fd); + return -1; + } + + uint8_t* buf = NULL; + if (cfg_len > 0) { + buf = (uint8_t*)malloc(cfg_len); + if (buf == NULL) { + close(fd); + return -1; + } + if (read(fd, buf, cfg_len) != (ssize_t)cfg_len) { + free(buf); + close(fd); + return -1; + } + } + + close(fd); + *out_bytes = buf; + *out_len = (size_t)cfg_len; + return 0; +} + static snapent_decoded_host_t* snapent_find_decoded_host( snapent_decoded_host_t* hosts, int count, const char* name) { @@ -1360,6 +1496,171 @@ static void test_snapent_checkpoint_no_hosts_yields_empty_host_section(void) printf("test_snapent_checkpoint_no_hosts_yields_empty_host_section: PASS\n"); } +/* ─────────────────────────────────────────────────────────────────────── + * config-upgrade-snapshot Task 2.3 (Req 2.1, 2.3, 2.4, 5.1): config-bytes + * section content, absence, and determinism. + * ─────────────────────────────────────────────────────────────────────── */ + +/* A config file test payload -- content is irrelevant to write_snapshot + * (it reads raw bytes, never parses/interprets them), so any distinctive + * byte string proves the round-trip without needing a real ClusterConfig + * capnp blob. */ +static const char SNAPENT_CONFIG_TEST_PAYLOAD[] = + "snapent-config-upgrade-snapshot-task-2.3-test-payload"; + +/* Saves SNAPENT_CONFIG_TEST_PAYLOAD as f's currently-active config via the + * real conf_save_config path (config_repl.c) -- the exact mechanism + * read_active_config_bytes (persist.c) resolves against (conf/current -> + * conf/cluster.bin.). Requires snapent_wire_myconf to have been called + * on f first. */ +static void snapent_save_test_config(snapent_fixture_t* f) +{ + uint8_t sha[32]; + SHA256((const unsigned char*)SNAPENT_CONFIG_TEST_PAYLOAD, + sizeof(SNAPENT_CONFIG_TEST_PAYLOAD) - 1, sha); + TEST_ASSERT_EQUAL_INT(0, + conf_save_config(&f->myconf, (const uint8_t*)SNAPENT_CONFIG_TEST_PAYLOAD, + sizeof(SNAPENT_CONFIG_TEST_PAYLOAD) - 1, sha)); +} + +/* Task 2.3's own literal Observable: a checkpoint's config-bytes section + * content hash matches the currently active configuration -- checked here + * via an exact byte-for-byte memcmp against the source file's own bytes, + * which subsumes a hash match (identical bytes trivially hash identically, + * and is a strictly stronger, more direct assertion). */ +static void test_snapent_checkpoint_config_bytes_section_matches_source_file(void) +{ + snapent_fixture_t* f = snapent_make(0, 0); + TEST_ASSERT_NOT_NULL(f); + + snapent_wire_myconf(f); + snapent_save_test_config(f); + + TEST_ASSERT_EQUAL_INT(0, persist_checkpoint(&f->c->persist)); + + char path[4096]; + snprintf(path, sizeof(path), "%s/snap-%016" PRIx64 ".capnp", + f->c->persist.snap_path, f->c->persist.checkpoint_lsn); + + uint8_t* section_bytes = NULL; + size_t section_len = 0; + TEST_ASSERT_EQUAL_INT_MESSAGE(0, + snapent_read_checkpoint_config_bytes(path, §ion_bytes, §ion_len), + "checkpoint file must parse as a well-formed entity-bearing payload " + "through the trailing config-bytes section"); + + TEST_ASSERT_EQUAL_size_t_MESSAGE(sizeof(SNAPENT_CONFIG_TEST_PAYLOAD) - 1, + section_len, + "config-bytes section length must equal the source config file's " + "exact byte length"); + TEST_ASSERT_EQUAL_MEMORY_MESSAGE(SNAPENT_CONFIG_TEST_PAYLOAD, section_bytes, + section_len, + "config-bytes section content must be byte-for-byte identical to " + "the currently active configuration on disk (Requirement 2.1) -- " + "an exact match trivially implies the content hash also matches"); + + free(section_bytes); + snapent_teardown(f); + printf("test_snapent_checkpoint_config_bytes_section_matches_source_file: PASS\n"); +} + +/* Requirement 2.4's "no config yet" edge: a live cluster (real myconf wired, + * unlike every other test in this file which leaves f->c->myconf NULL) that + * has never received a config push must still produce a valid checkpoint -- + * a zero-length config-bytes section, not a failed checkpoint. Mirrors + * test_snapent_checkpoint_no_hosts_yields_empty_host_section's "absence is + * not failure" shape, one level up (persist.c's read_active_config_bytes + * -ENOENT path). */ +static void test_snapent_checkpoint_config_bytes_section_empty_when_no_local_config(void) +{ + snapent_fixture_t* f = snapent_make(0, 0); + TEST_ASSERT_NOT_NULL(f); + + snapent_wire_myconf(f); /* real myconf, but conf_save_config never called */ + + TEST_ASSERT_EQUAL_INT_MESSAGE(0, persist_checkpoint(&f->c->persist), + "a checkpoint on a live cluster that has not yet received its first " + "config push must still succeed -- absence of local config bytes is " + "a benign early-boot state, never a checkpoint failure"); + + char path[4096]; + snprintf(path, sizeof(path), "%s/snap-%016" PRIx64 ".capnp", + f->c->persist.snap_path, f->c->persist.checkpoint_lsn); + + uint8_t* section_bytes = NULL; + size_t section_len = 0; + TEST_ASSERT_EQUAL_INT(0, + snapent_read_checkpoint_config_bytes(path, §ion_bytes, §ion_len)); + TEST_ASSERT_EQUAL_size_t_MESSAGE(0, section_len, + "no local config bytes exist yet -- the section must be explicitly " + "zero-length, not omitted or a phantom non-empty blob"); + TEST_ASSERT_NULL(section_bytes); + + snapent_teardown(f); + printf("test_snapent_checkpoint_config_bytes_section_empty_when_no_local_config: PASS\n"); +} + +/* Requirement 5.1 determinism: two independent checkpoints taken with the + * SAME active configuration unchanged on disk must produce byte-identical + * config-bytes sections -- a pure function of current on-disk content, no + * timestamp/ordering dependence. + * + * NOTE on why this does NOT also assert the two checkpoints land at + * different LSNs/paths: this fixture is a bare persist_t with no live commit + * engine (snapent_make's own doc comment -- "Deliberately OMITTED ... + * commit engine"), so appending via persist_wal_async never advances + * committed_lsn, and checkpoint_lsn (persist_checkpoint's + * last_applied_lsn snapshot) never moves past its initial value here -- + * both checkpoints legitimately land at the SAME path. That is fine for + * what THIS test checks: write_snapshot's config-bytes-writing logic still + * runs twice, as two genuinely separate persist_checkpoint calls (the + * second's open/write/rename fully redoes the first's, it does not skip or + * cache anything), which is exactly the "independent materialization" + * property Req 5.1 requires -- proving it does not depend on the path + * differing. */ +static void test_snapent_checkpoint_config_bytes_section_deterministic_across_checkpoints(void) +{ + snapent_fixture_t* f = snapent_make(0, 0); + TEST_ASSERT_NOT_NULL(f); + + snapent_wire_myconf(f); + snapent_save_test_config(f); + + TEST_ASSERT_EQUAL_INT(0, persist_checkpoint(&f->c->persist)); + char path1[4096]; + snprintf(path1, sizeof(path1), "%s/snap-%016" PRIx64 ".capnp", + f->c->persist.snap_path, f->c->persist.checkpoint_lsn); + + uint8_t* bytes1 = NULL; size_t len1 = 0; + TEST_ASSERT_EQUAL_INT(0, + snapent_read_checkpoint_config_bytes(path1, &bytes1, &len1)); + + TEST_ASSERT_EQUAL_INT(0, persist_checkpoint(&f->c->persist)); + char path2[4096]; + snprintf(path2, sizeof(path2), "%s/snap-%016" PRIx64 ".capnp", + f->c->persist.snap_path, f->c->persist.checkpoint_lsn); + + uint8_t* bytes2 = NULL; size_t len2 = 0; + TEST_ASSERT_EQUAL_INT(0, + snapent_read_checkpoint_config_bytes(path2, &bytes2, &len2)); + + TEST_ASSERT_EQUAL_size_t_MESSAGE(len1, len2, + "independent materializations at the same unchanged configuration " + "must yield equal-length config-bytes sections"); + TEST_ASSERT_EQUAL_MEMORY_MESSAGE(bytes1, bytes2, len1, + "independent materializations at the same unchanged configuration " + "must yield byte-identical config-bytes sections (Requirement 5.1)"); + TEST_ASSERT_EQUAL_size_t_MESSAGE(sizeof(SNAPENT_CONFIG_TEST_PAYLOAD) - 1, + len1, + "sanity: both materializations must actually carry the real config " + "content, not two empty sections trivially matching each other"); + + free(bytes1); + free(bytes2); + snapent_teardown(f); + printf("test_snapent_checkpoint_config_bytes_section_deterministic_across_checkpoints: PASS\n"); +} + /* ─────────────────────────────────────────────────────────────────────── * ha-entity-snapshots Task 4.2 (Req 3.1-3.3): rotation-guard RED test. * @@ -1744,6 +2045,9 @@ void test_persist_snapshot_entities(void) RUN_TEST(test_snapent_checkpoint_host_admin_status_roundtrip); RUN_TEST(test_snapent_checkpoint_closed_host_yields_exactly_one_admin_only_frame); RUN_TEST(test_snapent_checkpoint_no_hosts_yields_empty_host_section); + RUN_TEST(test_snapent_checkpoint_config_bytes_section_matches_source_file); + RUN_TEST(test_snapent_checkpoint_config_bytes_section_empty_when_no_local_config); + RUN_TEST(test_snapent_checkpoint_config_bytes_section_deterministic_across_checkpoints); RUN_TEST(test_snapent_rotation_preserves_in_flight_checkpoint); RUN_TEST(test_snapent_load_snapshot_restores_entities_before_advancing_position); RUN_TEST(test_snapent_load_snapshot_corrupted_entity_leaves_position_unchanged);