feat(config-upgrade-snapshot): add configcompare tool, extend snapverify (task 8.4)
Build configcompare_paths(), an offline sibling to snapdiff/walcompare that recomputes each member's active-config sha256 fresh from its own on-disk workdir/name/conf bytes and reports identical vs. divergent (Req 4.2). Wire it as a cmd_configcompare CLI subcommand in test.c, matching its siblings, after review caught it shipped as an unreachable library function in round 1. Extend snapverify to match the current on-disk checkpoint format: bump SNAP_FORMAT_VERSION to 2, replace the stale raw queue-frame decode with the capnp length-prefixed walk, add host-frame and config-bytes-section structural walks, and decode (not just presence-check) the header's cfgref tag/sha. Without this fix snapverify rejected every checkpoint written by the current codebase.
This commit is contained in:
parent
c5d8ecbd7d
commit
1d09631df0
10 changed files with 997 additions and 47 deletions
219
src/controller/testsuite/configcompare.c
Normal file
219
src/controller/testsuite/configcompare.c
Normal file
|
|
@ -0,0 +1,219 @@
|
|||
#define _GNU_SOURCE
|
||||
#include "configcompare.h"
|
||||
|
||||
#include <errno.h>
|
||||
#include <inttypes.h>
|
||||
#include <openssl/sha.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <sys/stat.h>
|
||||
|
||||
/* Bound on a member's config bytes this tool will read -- mirrors
|
||||
* persist.c's PERSIST_CONFIG_BYTES_MAX_SIZE / config_repl.h's CONF_MAX_SIZE
|
||||
* (1 MiB) exactly. Duplicated as its own local constant rather than
|
||||
* including either header, for the same reason the resolve-then-read logic
|
||||
* below is duplicated -- see read_member_config_sha256()'s doc comment. */
|
||||
#define CONFIGCOMPARE_CONFIG_MAX_SIZE (1u * 1024u * 1024u)
|
||||
|
||||
/* Resolve `<conf_dir>/conf/current` -> `<conf_dir>/conf/<versioned file>`,
|
||||
* read its raw bytes, and hash them into `out_sha`.
|
||||
*
|
||||
* DELIBERATE DUPLICATION -- this is the FOURTH independent copy of this
|
||||
* exact resolve-then-read-then-hash logic in this codebase:
|
||||
* 1. config_repl.c's conf_read_current_filename() + conf_read_raw()
|
||||
* (the production implementation -- config replication).
|
||||
* 2. config_repl.c's local_config_sha256() (activation-fence hash check;
|
||||
* an inline copy for the same "no cross-file call needed here"
|
||||
* reason as #4 below).
|
||||
* 3. persist.c's read_active_config_bytes() (checkpoint config-bytes
|
||||
* embedding; see its own doc comment for the full "why duplicate,
|
||||
* not call" rationale -- avoiding a persist.c -> config_repl.c
|
||||
* layering dependency).
|
||||
* 4. This function. configcompare.c is compiled into the standalone
|
||||
* xce_test CLI (meson.build's xce_test_src), which links ONLY
|
||||
* libxce_dep / librtti_dep / struct2json_dep / readline_dep /
|
||||
* history_dep / ncurses_dep / tinylog_dep / zlib_dep /
|
||||
* c_capnproto_dep -- deliberately NOT config_repl.c, persist.c,
|
||||
* cluster.c, or any other heavy controller object file. This is a
|
||||
* lightweight, dependency-light offline CLI tool family
|
||||
* (walcompare/snapdiff/snapverify all share the same constraint; no
|
||||
* live RPC to any cluster member, consistent with every other tool
|
||||
* in this family -- design.md "Regression Tooling"). Adding a
|
||||
* config_repl.c dependency to link in one function would violate
|
||||
* that boundary, so the ~25-line resolve+read is duplicated inline
|
||||
* here instead, exactly as persist.c's read_active_config_bytes()
|
||||
* already chose to do for the identical reason.
|
||||
*
|
||||
* Returns 0 with `out_sha` filled on success. Returns a negative errno on
|
||||
* any failure to resolve or read: -ENOENT (no conf/current pointer, or it
|
||||
* names a versioned file that is not present -- benign "not provisioned
|
||||
* yet" as well as genuine absence, both surfaced identically to the
|
||||
* caller as "unreadable"), -EINVAL (malformed pointer file: empty,
|
||||
* embedded '/', wrong prefix, wrong hex length), -EIO (short
|
||||
* read/zero-byte versioned file), -EFBIG (versioned file exceeds
|
||||
* CONFIGCOMPARE_CONFIG_MAX_SIZE), or -ENOMEM. This is the caller's
|
||||
* "unreadable/absent" signal -- distinct from and never conflated with a
|
||||
* mismatched hash. */
|
||||
static int read_member_config_sha256(const char* conf_dir, uint8_t out_sha[32])
|
||||
{
|
||||
if (!conf_dir || !*conf_dir)
|
||||
return -EINVAL;
|
||||
|
||||
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 member 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;
|
||||
|
||||
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 */
|
||||
|
||||
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 > CONFIGCOMPARE_CONFIG_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);
|
||||
|
||||
SHA256(buf, (size_t)sz, out_sha);
|
||||
free(buf);
|
||||
return 0;
|
||||
}
|
||||
|
||||
static const char* sha256_hex(const uint8_t sha[32], char out[65])
|
||||
{
|
||||
static const char digits[] = "0123456789abcdef";
|
||||
for (int i = 0; i < 32; i++) {
|
||||
out[i * 2] = digits[(sha[i] >> 4) & 0xF];
|
||||
out[i * 2 + 1] = digits[sha[i] & 0xF];
|
||||
}
|
||||
out[64] = '\0';
|
||||
return out;
|
||||
}
|
||||
|
||||
int configcompare_paths(const char* const* conf_paths, int n, FILE* out,
|
||||
configcompare_report_t* rep)
|
||||
{
|
||||
configcompare_report_t local;
|
||||
memset(&local, 0, sizeof(local));
|
||||
local.first_divergent_index = -1;
|
||||
|
||||
if (!conf_paths || n < 2) {
|
||||
if (rep)
|
||||
*rep = local;
|
||||
if (out)
|
||||
fprintf(out,
|
||||
"configcompare: nothing to compare (need >= 2 members, "
|
||||
"got %d)\n", n);
|
||||
return -EINVAL;
|
||||
}
|
||||
if (n > CONFIGCOMPARE_MAX_MEMBERS) {
|
||||
if (rep)
|
||||
*rep = local;
|
||||
if (out)
|
||||
fprintf(out, "configcompare: too many members (%d > max %d)\n", n,
|
||||
CONFIGCOMPARE_MAX_MEMBERS);
|
||||
return -EINVAL;
|
||||
}
|
||||
|
||||
local.n = n;
|
||||
int first_ok = -1;
|
||||
char hexbuf[65];
|
||||
|
||||
for (int i = 0; i < n; i++) {
|
||||
int rc = read_member_config_sha256(conf_paths[i], local.sha256[i]);
|
||||
local.read_rc[i] = rc;
|
||||
|
||||
if (rc != 0) {
|
||||
if (out)
|
||||
fprintf(out,
|
||||
"configcompare: member %d (%s): UNREADABLE: %s\n", i,
|
||||
conf_paths[i] ? conf_paths[i] : "(null)",
|
||||
strerror(-rc));
|
||||
if (rep)
|
||||
*rep = local;
|
||||
/* Short-circuit on the first unreadable member, mirroring
|
||||
* snapdiff_paths's early-return-on-open-failure style: a
|
||||
* member we could not read is not evidence of matching OR
|
||||
* mismatching configuration, so there is no point continuing
|
||||
* to accumulate a verdict that will be discarded anyway. */
|
||||
return rc;
|
||||
}
|
||||
|
||||
if (out)
|
||||
fprintf(out, "configcompare: member %d (%s): sha256=%s\n", i,
|
||||
conf_paths[i], sha256_hex(local.sha256[i], hexbuf));
|
||||
|
||||
if (first_ok < 0) {
|
||||
first_ok = i;
|
||||
} else if (local.first_divergent_index < 0 &&
|
||||
memcmp(local.sha256[i], local.sha256[first_ok], 32) != 0) {
|
||||
local.first_divergent_index = i;
|
||||
}
|
||||
}
|
||||
|
||||
local.all_present = 1;
|
||||
local.all_identical = (local.first_divergent_index < 0);
|
||||
|
||||
if (rep)
|
||||
*rep = local;
|
||||
|
||||
if (!local.all_identical) {
|
||||
if (out)
|
||||
fprintf(out,
|
||||
"configcompare: DIVERGENT -- member %d differs from "
|
||||
"member %d\n",
|
||||
local.first_divergent_index, first_ok);
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (out)
|
||||
fprintf(out, "configcompare: IDENTICAL across %d members\n", n);
|
||||
return 0;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue