/* startup_validation.c — Startup pre-flight validation functions. * * Implements validate_vcpus(), validate_startup_config(), * validate_peer_count(), validate_cert_files(), * validate_heartbeat_config(), and myapp_leader_already_known() for use in * main.c/myapp.c and the controller unit test suite. * * Requirements: 4.1 (peer count), 4.2 (cert pre-flight), * 4.3 (vcpus), 4.4 (vcpus key deprecation warn), * 2.4 (heartbeat timing validation) * * myapp_leader_already_known lives here (not myapp.c) purely so it links * into controller_unit_test — meson's controller_unit_test_src does not * build myapp.c (myapp_run is an untestable integration entry point * requiring a live network socket), but it already builds this file for * the other validate_* helpers above (bug-1695). */ #define _GNU_SOURCE #include #include #include #include #include #include "ha.h" /* ── Req 4.3: --vcpus validation ─────────────────────────────────────── */ /* * validate_vcpus: returns 0 if vcpus >= 1, -EINVAL otherwise. * Called from main.c after argp_parse() sets arguments.vcpus. */ int validate_vcpus(int vcpus) { if (vcpus <= 0) { return -EINVAL; } return 0; } /* ── Req 4.4: vcpus key in config file — warn only ───────────────────── */ /* * validate_startup_config: scan config_path for a "vcpus" key. * If found, write a deprecation warning into warn_buf (must contain "vcpus"). * Always returns 0 — the presence of a vcpus key is not a fatal error. * warn_buf is left empty (first byte = '\0') when no vcpus key is found. * * The scan is line-based: a line contains the vcpus key if it starts with * optional whitespace followed by the literal string "vcpus". */ int validate_startup_config(const char *config_path, char *warn_buf, size_t warn_len) { if (warn_buf && warn_len > 0) { warn_buf[0] = '\0'; } if (!config_path) { return 0; } FILE *fp = fopen(config_path, "r"); if (!fp) { return 0; /* can't open — not a fatal error at this stage */ } char line[1024]; while (fgets(line, sizeof(line), fp)) { /* skip leading whitespace */ const char *p = line; while (*p == ' ' || *p == '\t') { p++; } if (strncmp(p, "vcpus", 5) == 0) { /* Found a vcpus key — write deprecation warning */ if (warn_buf && warn_len > 0) { snprintf(warn_buf, warn_len, "config file contains deprecated 'vcpus' key; " "use --vcpus CLI parameter instead"); } break; } } fclose(fp); return 0; } /* ── Req 4.1: even-N peer count rejection ─────────────────────────────── */ /* * validate_peer_count: returns 0 for valid odd counts (1, 3, 5, ...), * -EINVAL for even counts > 1. * n_peers == 1 is single-node mode — always valid. * Even counts > 1 cannot form a safe majority partition. */ int validate_peer_count(int n_peers) { if (n_peers > 1 && (n_peers % 2) == 0) { return -EINVAL; } return 0; } /* ── Req 4.2: TLS cert pre-flight ─────────────────────────────────────── */ /* * validate_cert_files: check that cert_path and key_path are readable. * On the first missing/unreadable file, sets err_buf to a message * containing the full file path and returns -ENOENT. * Returns 0 if both files are accessible. */ int validate_cert_files(const char *cert_path, const char *key_path, char *err_buf, size_t err_len) { if (access(cert_path, R_OK) != 0) { if (err_buf && err_len > 0) { snprintf(err_buf, err_len, "TLS certificate not found or unreadable: %s", cert_path); } return -ENOENT; } if (access(key_path, R_OK) != 0) { if (err_buf && err_len > 0) { snprintf(err_buf, err_len, "TLS key not found or unreadable: %s", key_path); } return -ENOENT; } return 0; } /* ── Req 2.4: heartbeat timing validation ─────────────────────────────── */ /* * validate_heartbeat_config: enforce heartbeat_timeout_ms >= 2 * heartbeat_interval_ms. * * A timeout smaller than two intervals means the standby could declare the * primary dead before a single missed heartbeat has elapsed — that is an * operator misconfiguration that must be caught at startup. * * Returns 0 when the relationship holds (valid config). * Returns -EINVAL when heartbeat_timeout_ms < 2 * heartbeat_interval_ms; * writes a human-readable error message into err_buf. */ int validate_heartbeat_config(uint32_t heartbeat_timeout_ms, uint32_t heartbeat_interval_ms, char *err_buf, size_t err_len) { if (err_buf && err_len > 0) { err_buf[0] = '\0'; } if (heartbeat_timeout_ms < 2 * heartbeat_interval_ms) { if (err_buf && err_len > 0) { snprintf(err_buf, err_len, "heartbeat_timeout_ms (%u) must be >= 2 * heartbeat_interval_ms (%u); " "current ratio %.2f is below the minimum of 2.0 — " "set heartbeat_timeout_ms >= %u", heartbeat_timeout_ms, heartbeat_interval_ms, heartbeat_interval_ms > 0 ? (double)heartbeat_timeout_ms / (double)heartbeat_interval_ms : 0.0, 2 * heartbeat_interval_ms); } return -EINVAL; } return 0; } /* ── bug-1695: myapp_run's "leader already known" bootstrap check ───────── */ /* * myapp_leader_already_known — bootstrap "is a leader already known" decision * (bug-1695), extracted from myapp_run()'s post-stagger check so it is unit * testable (myapp_run itself is an untestable integration entry point that * requires a live network socket — see test_ha_startup.c's Task 5.1 note). * * Returns 1 (skip this node's own election) when ANY of: * * (a) ha->voted_for_name records a vote for a DIFFERENT candidate cast in * the CURRENT term (voted_for_term == ha->term). This node granted a * vote to a real candidate, so it is a follower, not unaware of any * candidacy. A self-vote (own name, or the test-mode "self" * placeholder) does NOT count — a self-vote means THIS node was the * candidate, so no external leader is known. * * (b) ha->raft.term has genuinely ADVANCED past term_at_boot — the value * the caller must capture immediately after ha_init_raft() seeds * ha->raft.term from the just-restored on-disk term * (ha_restore_term(), called from cluster_init_phase1 before * ha_init_raft), strictly BEFORE the startup-stagger nap. Only an * advance DURING this boot's nap — real vote-request/heartbeat * traffic actually processed by iomgr, via * raft_on_vote_request's/raft_on_append_entries_request's UpdateTerm * steps — counts as evidence a leader is known. A merely-nonzero * RESTORED term tells us nothing about whether any leader is * currently reachable: Raft terms only ever increase, so comparing * against a bare 0 (the pre-bug-1695 check) was trivially true on * every restart after the cluster's first-ever election, permanently * short-circuiting every later election attempt. * * (c) primary_ctrl_fd is already connected — the winner's ctrlhello has * already established the control channel. * * bug-1695 verification notes (see myapp_run's caller-side comment for the * companion snapshot-timing argument): * * - First-ever-boot case: ha_restore_term() leaves ha->term (and therefore * the seeded ha->raft.term) at 0 when no ha-term.bin exists yet, so * term_at_boot == 0 — identical to the old `raft.term > 0` check for a * first boot. Preserved. * * - No other path can advance ha->raft.term between the caller's snapshot * and this check in a way that would be wrongly treated as "stale": the * only writers of raft.term after ha_init_raft() are raft_cb_persist * (fired from raft_on_vote_request / raft_on_append_entries_request / * raft_start_election), all of which only run in response to genuine * incoming or self-initiated raft traffic processed by iomgr during the * nap — exactly the signal this check intends to detect. * * - This does not introduce a new "spurious own election while a leader is * genuinely live but not yet observed" failure mode beyond what already * exists: that exact race (nap elapsed, term not yet advanced, primary_ * ctrl_fd not yet connected) is the SAME one the cluster's first-ever * boot already runs through today (term_at_boot is always 0 pre-first- * election, so `raft.term > term_at_boot` was already the operative * comparison then) — this fix merely re-applies that already-proven * first-boot behavior to every later restart too, instead of only the * first one. Any resulting term churn is ordinary, tolerated Raft * UpdateTerm behavior (a live primary that sees a higher-term * RequestVote steps down and the cluster re-converges); it does not * weaken Election Safety, which rests entirely on votedFor + majority * inside the raft library, not on this advisory bootstrap check. * * - The voted_for_name/vft==cur check (a) is deliberately left untouched. * It is NOT obviously immune to the same "restored state is * indistinguishable from fresh state" class of question — a restored * (voted_for_term, term) pair is just as internally consistent as a * live one, since both are written together by the same on_persist * call. Whether that residual staleness is DEFECTIVE, though, hinges * on the separate liveness-watchdog/PreVote-failover subsystem (out of * this fix's scope) reliably recovering a standby that skips via this * path while its remembered leader is actually dead — that subsystem is * untouched here and evaluated empirically via this fix's live soak, * not modified speculatively. * * Locking: ha->lock guards voted_for_name/voted_for_term (same discipline as * the original inline check); ha->term is read atomically; ha->raft.term and * primary_ctrl_fd are read without a lock, matching the pre-existing * (unlocked) read discipline at this call site — both are advisory reads for * a bootstrap fast-path decision, not a safety-load-bearing one: a false * negative here only costs a redundant, safely-arbitrated election. */ int myapp_leader_already_known(ha_state_t *ha, const char *my_name, uint64_t term_at_boot) { int leader_known = 0; pthread_mutex_lock(&ha->lock); uint64_t vft = ha->voted_for_term; uint64_t cur = atomic_load_explicit(&ha->term, memory_order_relaxed); if (ha->voted_for_name[0] != '\0' && strcmp(ha->voted_for_name, "self") != 0 && strcmp(ha->voted_for_name, my_name) != 0 && vft == cur) leader_known = 1; pthread_mutex_unlock(&ha->lock); /* bug-1695 fix: require a genuine ADVANCE past the caller's pre-nap * snapshot, not merely a nonzero restored term (see the function-level * doc comment above for the full rationale). */ if (!leader_known && ha->raft.term > term_at_boot) leader_known = 1; if (!leader_known && ha->primary_ctrl_fd >= 0) leader_known = 1; return leader_known; }