2495 lines
86 KiB
Markdown
2495 lines
86 KiB
Markdown
> **ARCHIVED — superseded.** This plan proposed a priority-based `am_i_primary()` election (non-blocking connect/poll probing with xorshift jitter) and a standalone single-ACK `OP_replConfigPush` fan-out (500ms timeout, no WAL involvement) for config replication. What actually shipped: primary election is quorum-based Raft (`ha_start_election_async`, replacing the now-deleted `am_i_primary`/`am_i_primary_quorum`/`probe_ctx_t` — see closed specs `election-iomgr-refactor`, `raft-log-replication`, `raft-commit-safety`, `ha-term-safety`), and config changes now travel as `WAL_CONFIG_MARKER` entries inside the Raft/WAL commit path (`ha-term-safety` task 4.1, `ha-commit-engine`) rather than an out-of-band push. The HEAD-file/SHA-256 versioned storage layer (`conf_save_config`, `conf_read_current_filename`, `config_repl.c`) did ship largely as described here and is still current.
|
||
|
||
# Config Replication Implementation Plan
|
||
|
||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||
|
||
**Goal:** Implement config replication so standbys fetch `ClusterConfig` from the primary on startup, eliminating manual per-node provisioning.
|
||
|
||
**Architecture:** Split `cluster_init()` into Phase 1 (SSL/HA init, no config) and Phase 2 (iomgr/kvmgr/persist, config required). The primary loads config from `conf/current` (HEAD-file pointer → versioned `cluster.bin.<sha256>` file). Standbys fetch via `OP_replConfig` before Phase 2. SHA-256 is used as both version tag and integrity token. `am_i_primary()` replaced with real election probing (non-blocking connect + poll per candidate).
|
||
|
||
**Tech Stack:** C99, OpenSSL SHA-256, POSIX threads (non-blocking connect + poll), Cap'n Proto codecgen, Unity test framework, Meson build.
|
||
|
||
---
|
||
|
||
## File Map
|
||
|
||
| File | Action | What changes |
|
||
|------|--------|--------------|
|
||
| `src/schema/controller.capnp` | Modify | Add 3 opcodes + 6 structs + `configGeneration` field + `sender_priority` to Heartbeat |
|
||
| `src/include/myconf.h` | Modify | Add 6 fields to `myconf_t` |
|
||
| `src/include/xce/cluster_def.h` | Modify | Add 4 fields to `cluster_t` |
|
||
| `src/controller/ha.h` | Modify | Add `ssl_ctx`, `jitter_seed`, `standby_endpoints`, `election_priority_step_ms`, `election_jitter_max_ms` to `ha_state_t`; add `probe_ctx_t`; new API declarations |
|
||
| `src/include/persist.h` | Modify | Add `max_wal_records` to `persist_config_t`; add `wal_record_count`, `checkpoint_in_progress` to `persist_s` |
|
||
| `src/include/byteorder.h` | Create | `be64toh()` portability fallback |
|
||
| `src/controller/config_repl.h` | Create | Public API for config_repl.c |
|
||
| `src/controller/config_repl.c` | Create | All config storage, SHA-256, fetch, upgrade, push functions |
|
||
| `src/controller/main.c` | Modify | HEAD-file pointer resolution; add `--conf-keep-n`, `--conf-stale-timeout` flags; heap-alloc conffile |
|
||
| `src/include/myconf.h` | Modify | `myconf_fini()` declaration |
|
||
| `src/controller/myconf.c` | Modify | Add `load_primary_config()`, `myconf_fini()`; remove `get_default_conf()` static global |
|
||
| `src/controller/cluster.c` | Modify | Split `cluster_init()` into Phase 1 + Phase 2; add opcode dispatch for new ops; `cluster_run()` checks `restart_requested`; `handle_endpoint_error` prunes `standby_endpoints` |
|
||
| `src/controller/myapp.c` | Modify | Restructure to call Phase 1, `am_i_primary()`, config load/fetch, Phase 2; handle restart |
|
||
| `src/controller/ha.c` | Modify | Replace `am_i_primary()` stub with real election probing; add `probe_thread`, heartbeat listener, `ha_check_heartbeat_nocluster` priority comparison |
|
||
| `src/controller/join.c` | Modify | Implement `handle_controller_join()`; register in `standby_endpoints` |
|
||
| `src/include/typemap.h` | Modify | Add new union arms to `xce_request_t` and `xce_reply_t` |
|
||
| `src/lib/request.c` | Modify | Add encode/decode shims for 6 new message types |
|
||
| `src/controller/persist.c` | Modify | Add `schedule_checkpoint()` triggered by `max_wal_records` |
|
||
| `src/controller/meson.build` | Modify | Add `config_repl.c`, compile-time defines |
|
||
| `src/controller/testsuite/test_config_repl.c` | Create | Unit tests for Phase 1 + Phase 2 config functions |
|
||
| `src/controller/testsuite/test_ha_role.c` | Modify | Extend with election probing tests |
|
||
| `src/controller/testsuite/controller_test.c` | Modify | Register new test suites |
|
||
|
||
---
|
||
|
||
## Task 1: Schema additions
|
||
|
||
**Files:**
|
||
- Modify: `src/schema/controller.capnp`
|
||
|
||
- [ ] **Step 1: Add new OPCode entries**
|
||
|
||
In `controller.capnp`, after `replCatchUpDone @28;`, add:
|
||
|
||
```capnp
|
||
replConfig @29;
|
||
replConfigPush @30;
|
||
upgradeConfig @31;
|
||
```
|
||
|
||
- [ ] **Step 2: Add new message structs**
|
||
|
||
After the `MasterCandidate` struct, add:
|
||
|
||
```capnp
|
||
struct ReplConfigRequest $C.mapname("repl_config_req_t") {
|
||
versionTag @0: UInt64 $C.mapname("version_tag");
|
||
}
|
||
|
||
struct ReplConfigReply $C.mapname("repl_config_reply_t") {
|
||
upToDate @0: Bool $C.mapname("up_to_date");
|
||
versionTag @1: UInt64 $C.mapname("version_tag");
|
||
sha256 @2: Data $C.mapname("sha256");
|
||
bytes @3: Data $C.mapname("bytes");
|
||
}
|
||
|
||
struct ReplConfigPushRequest $C.mapname("repl_config_push_req_t") {
|
||
versionTag @0: UInt64 $C.mapname("version_tag");
|
||
sha256 @1: Data $C.mapname("sha256");
|
||
bytes @2: Data $C.mapname("bytes");
|
||
}
|
||
|
||
struct ReplConfigPushReply $C.mapname("repl_config_push_reply_t") {
|
||
accepted @0: Bool $C.mapname("accepted");
|
||
versionTag @1: UInt64 $C.mapname("version_tag");
|
||
}
|
||
|
||
struct UpgradeConfigRequest $C.mapname("upgrade_config_req_t") {
|
||
bytes @0: Data $C.mapname("bytes");
|
||
sha256 @1: Data $C.mapname("sha256");
|
||
}
|
||
|
||
struct UpgradeConfigReply $C.mapname("upgrade_config_reply_t") {
|
||
versionTag @0: UInt64 $C.mapname("version_tag");
|
||
error @1: Text $C.mapname("error");
|
||
ackedCount @2: UInt32 $C.mapname("acked_count");
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Add union arms to Request.body**
|
||
|
||
In `struct Request`, after `hostctrlmsg @21: HostControl;`:
|
||
|
||
```capnp
|
||
replConfigRequestMsg @22: ReplConfigRequest;
|
||
replConfigPushRequestMsg @23: ReplConfigPushRequest;
|
||
upgradeConfigRequestMsg @24: UpgradeConfigRequest;
|
||
```
|
||
|
||
- [ ] **Step 4: Add union arms to RequestReply.data**
|
||
|
||
In `struct RequestReply`, after `clusterconf @7: ClusterConf;`:
|
||
|
||
```capnp
|
||
replConfigReplyMsg @8: ReplConfigReply;
|
||
replConfigPushReplyMsg @9: ReplConfigPushReply;
|
||
upgradeConfigReplyMsg @10: UpgradeConfigReply;
|
||
```
|
||
|
||
- [ ] **Step 5: Add configGeneration to ClusterConfig**
|
||
|
||
In `struct ClusterConfig`, after the last existing field, add:
|
||
|
||
```capnp
|
||
configGeneration @20: UInt64 $C.mapname("config_generation");
|
||
```
|
||
|
||
- [ ] **Step 6: Add sender_priority to a new Heartbeat struct**
|
||
|
||
Add a new struct (the schema currently has no Heartbeat struct — `OP_heartbeat` uses the nop/void path):
|
||
|
||
```capnp
|
||
struct HeartbeatMsg $C.mapname("heartbeat_msg_t") {
|
||
senderPriority @0: UInt32 $C.mapname("sender_priority");
|
||
term @1: UInt64 $C.mapname("term");
|
||
}
|
||
```
|
||
|
||
Then in `Request.body`, add:
|
||
|
||
```capnp
|
||
heartbeatmsg @25: HeartbeatMsg;
|
||
```
|
||
|
||
- [ ] **Step 7: Build to verify schema compiles**
|
||
|
||
```bash
|
||
cd /worktrees/persistent_ha/src && nix develop --command bash -c "meson setup build --reconfigure 2>&1 | tail -20 && ninja -C build src/schema/controller.capnp.h 2>&1 | tail -20"
|
||
```
|
||
|
||
Expected: no capnp parse errors, generated header updated.
|
||
|
||
- [ ] **Step 8: Commit**
|
||
|
||
```bash
|
||
git add src/schema/controller.capnp
|
||
git commit -m "feat: add replConfig/replConfigPush/upgradeConfig opcodes and message structs to schema"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 2: Struct field additions
|
||
|
||
**Files:**
|
||
- Modify: `src/include/myconf.h`
|
||
- Modify: `src/include/xce/cluster_def.h`
|
||
- Modify: `src/controller/ha.h`
|
||
- Modify: `src/include/persist.h`
|
||
- Create: `src/include/byteorder.h`
|
||
|
||
- [ ] **Step 1: Add fields to myconf_t**
|
||
|
||
In `src/include/myconf.h`, after `xce_cluster_config_t peers_cfg;`:
|
||
|
||
```c
|
||
uint64_t config_version_tag; /* first 8 bytes of SHA-256, host order */
|
||
uint8_t config_sha256[32]; /* full SHA-256 of conffile bytes */
|
||
int conf_keep_n; /* versioned file retention count (default 2) */
|
||
uint64_t config_generation; /* monotonic audit counter (§4.4) */
|
||
time_t conf_stale_timeout; /* seconds before saved config is flagged stale (default 86400) */
|
||
int config_stale; /* set when saved config is older than conf_stale_timeout */
|
||
```
|
||
|
||
Also add `myconf_fini()` declaration at the bottom of the file:
|
||
|
||
```c
|
||
void myconf_fini(myconf_t *myconf);
|
||
```
|
||
|
||
- [ ] **Step 2: Add fields to cluster_t**
|
||
|
||
In `src/include/xce/cluster_def.h`, inside `struct cluster_s`, after `ha_state_t ha;`:
|
||
|
||
```c
|
||
_Atomic int restart_requested; /* set by OP_upgradeConfig handler */
|
||
pthread_t heartbeat_listener_tid; /* pre-Phase-2 SSL listener thread */
|
||
_Atomic int heartbeat_listener_running;
|
||
_Atomic int lost_and_found_job_count; /* incremented during persist_recover() */
|
||
```
|
||
|
||
- [ ] **Step 3: Add fields to ha_state_t and ha_config_t**
|
||
|
||
In `src/controller/ha.h`:
|
||
|
||
(a) In `ha_config_t`, after `max_promotion_retries`:
|
||
```c
|
||
uint32_t election_jitter_max_ms; /* random jitter added to election timeout */
|
||
uint32_t election_priority_step_ms; /* extra ms per priority level */
|
||
```
|
||
|
||
(b) Add new default macros after the existing defaults:
|
||
```c
|
||
#define HA_DEFAULT_ELECTION_JITTER_MAX_MS 200
|
||
#define HA_DEFAULT_ELECTION_PRIORITY_STEP_MS 500
|
||
```
|
||
|
||
(c) In `ha_state_t`, after `watchdog_thread`:
|
||
```c
|
||
void *ssl_ctx; /* SSL_CTX* cast; avoids pulling in openssl headers */
|
||
uint64_t jitter_seed; /* xorshift64 PRNG state for election jitter */
|
||
zarray_t *standby_endpoints; /* connected standby controller endpoints */
|
||
```
|
||
|
||
(d) Add `probe_ctx_t` struct declaration before `ha_state_t`:
|
||
```c
|
||
typedef struct {
|
||
struct xce_candidate_s *candidate; /* peer to probe */
|
||
void *ssl_ctx; /* SSL_CTX* (void* to avoid openssl header) */
|
||
uint32_t timeout_ms;
|
||
atomic_int *election_done;
|
||
int got_ack;
|
||
pthread_t thread;
|
||
} probe_ctx_t;
|
||
```
|
||
|
||
(e) Add new API declarations at the bottom:
|
||
```c
|
||
int ha_is_primary(const ha_state_t *ha);
|
||
void start_heartbeat_listener(cluster_t *c);
|
||
void stop_heartbeat_listener(cluster_t *c);
|
||
```
|
||
|
||
- [ ] **Step 4: Add fields to persist_config_t and persist_s**
|
||
|
||
In `src/include/persist.h`, in `persist_config_t` after `sync_timeout_ms`:
|
||
```c
|
||
uint32_t max_wal_records; /* checkpoint trigger: records since last snap (default 10000) */
|
||
```
|
||
|
||
In `persist_config_defaults()`, add:
|
||
```c
|
||
c.max_wal_records = 10000;
|
||
```
|
||
|
||
In `struct persist_s` after `pthread_mutex_t lock;`:
|
||
```c
|
||
_Atomic uint32_t wal_record_count; /* records since last checkpoint */
|
||
_Atomic int checkpoint_in_progress; /* CAS guard for schedule_checkpoint */
|
||
```
|
||
|
||
- [ ] **Step 5: Create byteorder.h**
|
||
|
||
Create `src/include/byteorder.h`:
|
||
|
||
```c
|
||
#pragma once
|
||
/* Portable be64toh() for non-glibc or pre-2.25 targets.
|
||
* On Linux glibc >= 2.9 this is already available from <endian.h>.
|
||
* On other platforms we provide a fallback. */
|
||
#if defined(__linux__)
|
||
# include <endian.h>
|
||
#elif defined(__APPLE__)
|
||
# include <machine/endian.h>
|
||
# include <libkern/OSByteOrder.h>
|
||
# define be64toh(x) OSSwapBigToHostInt64(x)
|
||
#else
|
||
# include <stdint.h>
|
||
static inline uint64_t be64toh(uint64_t x) {
|
||
# if __BYTE_ORDER__ == __ORDER_LITTLE_ENDIAN__
|
||
return __builtin_bswap64(x);
|
||
# else
|
||
return x;
|
||
# endif
|
||
}
|
||
#endif
|
||
```
|
||
|
||
- [ ] **Step 6: Build to verify all struct changes compile**
|
||
|
||
```bash
|
||
cd /worktrees/persistent_ha/src && nix develop --command bash -c "ninja -C build 2>&1 | grep -E 'error:|warning:|ninja:' | head -30"
|
||
```
|
||
|
||
Expected: only warnings about new fields being unused — no errors.
|
||
|
||
- [ ] **Step 7: Commit**
|
||
|
||
```bash
|
||
git add src/include/myconf.h src/include/xce/cluster_def.h src/controller/ha.h src/include/persist.h src/include/byteorder.h
|
||
git commit -m "feat: add config replication fields to myconf_t, cluster_t, ha_state_t, persist_t"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 3: config_repl.h + config_repl.c (Phase 1 storage functions)
|
||
|
||
**Files:**
|
||
- Create: `src/controller/config_repl.h`
|
||
- Create: `src/controller/config_repl.c` (Phase 1 functions only)
|
||
- Create: `src/controller/testsuite/test_config_repl.c` (Phase 1 tests)
|
||
|
||
- [ ] **Step 1: Write failing tests for Phase 1 storage functions**
|
||
|
||
Create `src/controller/testsuite/test_config_repl.c`:
|
||
|
||
```c
|
||
/* test_config_repl.c — Unity tests for config replication (Phase 1) */
|
||
#include "unity.h"
|
||
#include <fcntl.h>
|
||
#include <stdio.h>
|
||
#include <stdlib.h>
|
||
#include <string.h>
|
||
#include <sys/stat.h>
|
||
#include <unistd.h>
|
||
#include "config_repl.h"
|
||
|
||
static char tmpdir[256];
|
||
|
||
void setUp(void) {
|
||
snprintf(tmpdir, sizeof(tmpdir), "/tmp/test_config_repl_%d", (int)getpid());
|
||
mkdir(tmpdir, 0700);
|
||
char conf_dir[300];
|
||
snprintf(conf_dir, sizeof(conf_dir), "%s/conf", tmpdir);
|
||
mkdir(conf_dir, 0700);
|
||
}
|
||
|
||
void tearDown(void) {
|
||
char cmd[300];
|
||
snprintf(cmd, sizeof(cmd), "rm -rf %s", tmpdir);
|
||
system(cmd);
|
||
}
|
||
|
||
/* test: conf_read_current_filename() returns -ENOENT when conf/current absent */
|
||
void test_conf_read_current_filename_missing(void) {
|
||
char out[256] = {0};
|
||
int rc = conf_read_current_filename(tmpdir, out, sizeof(out));
|
||
TEST_ASSERT_EQUAL_INT(-ENOENT, rc);
|
||
}
|
||
|
||
/* test: conf_save_config() writes versioned file + HEAD pointer; read back matches */
|
||
void test_conf_save_and_read_current(void) {
|
||
const uint8_t data[] = "fake capnproto config blob";
|
||
size_t len = sizeof(data) - 1;
|
||
uint8_t sha256[32];
|
||
SHA256(data, len, sha256);
|
||
|
||
myconf_t myconf = {0};
|
||
myconf.workdir = tmpdir;
|
||
myconf.name = "ctrl0";
|
||
myconf.conf_keep_n = 2;
|
||
|
||
int rc = conf_save_config(&myconf, data, len, sha256);
|
||
TEST_ASSERT_EQUAL_INT(0, rc);
|
||
|
||
/* conf/current pointer file must exist */
|
||
char ptr_path[512];
|
||
snprintf(ptr_path, sizeof(ptr_path), "%s/conf/current", tmpdir);
|
||
TEST_ASSERT_EQUAL_INT(0, access(ptr_path, F_OK));
|
||
|
||
/* Reading back the current filename must succeed */
|
||
char out[256] = {0};
|
||
rc = conf_read_current_filename(tmpdir, out, sizeof(out));
|
||
TEST_ASSERT_EQUAL_INT(0, rc);
|
||
TEST_ASSERT_NOT_NULL(strstr(out, "cluster.bin."));
|
||
}
|
||
|
||
/* test: conf_save_config() then conf_read_current_filename() parses 12-hex suffix */
|
||
void test_conf_current_filename_format(void) {
|
||
const uint8_t data[] = "another fake blob for hex test";
|
||
size_t len = sizeof(data) - 1;
|
||
uint8_t sha256[32];
|
||
SHA256(data, len, sha256);
|
||
|
||
myconf_t myconf = {0};
|
||
myconf.workdir = tmpdir;
|
||
myconf.name = "ctrl0";
|
||
myconf.conf_keep_n = 2;
|
||
conf_save_config(&myconf, data, len, sha256);
|
||
|
||
char out[256] = {0};
|
||
conf_read_current_filename(tmpdir, out, sizeof(out));
|
||
|
||
/* Must start with "cluster.bin." */
|
||
TEST_ASSERT_EQUAL_INT(0, strncmp(out, "cluster.bin.", 12));
|
||
/* Suffix must be exactly 12 hex chars */
|
||
const char *hex = out + 12;
|
||
TEST_ASSERT_EQUAL_INT(12, (int)strlen(hex));
|
||
TEST_ASSERT_EQUAL_INT(12, (int)strspn(hex, "0123456789abcdef"));
|
||
}
|
||
|
||
/* test: conf_trim_old_versions() keeps only conf_keep_n files */
|
||
void test_conf_trim_keep_n(void) {
|
||
myconf_t myconf = {0};
|
||
myconf.workdir = tmpdir;
|
||
myconf.name = "ctrl0";
|
||
myconf.conf_keep_n = 2;
|
||
uint8_t sha256[32];
|
||
|
||
/* Write 3 different config versions */
|
||
for (int i = 0; i < 3; i++) {
|
||
char data[32];
|
||
snprintf(data, sizeof(data), "config version %d", i);
|
||
SHA256((uint8_t*)data, strlen(data), sha256);
|
||
usleep(10000); /* ensure distinct mtime */
|
||
conf_save_config(&myconf, (uint8_t*)data, strlen(data), sha256);
|
||
}
|
||
|
||
/* Count cluster.bin.* files (excluding .tmp) */
|
||
char conf_dir[300];
|
||
snprintf(conf_dir, sizeof(conf_dir), "%s/conf", tmpdir);
|
||
int count = 0;
|
||
DIR *d = opendir(conf_dir);
|
||
struct dirent *e;
|
||
while ((e = readdir(d)) != NULL) {
|
||
if (strncmp(e->d_name, "cluster.bin.", 12) == 0 &&
|
||
strstr(e->d_name, ".tmp") == NULL)
|
||
count++;
|
||
}
|
||
closedir(d);
|
||
TEST_ASSERT_EQUAL_INT(2, count);
|
||
}
|
||
|
||
/* test: zero-tag sentinel — standby with no config sends tag=0 */
|
||
void test_conf_zero_tag_is_no_config_sentinel(void) {
|
||
/* When conf/current is absent, compute_config_tag() returns 0 */
|
||
uint64_t tag = conf_get_saved_tag(tmpdir);
|
||
TEST_ASSERT_EQUAL_UINT64(0, tag);
|
||
}
|
||
|
||
/* test: conf_read_current_filename() returns -EINVAL on path separator in pointer */
|
||
void test_conf_current_filename_rejects_path_separator(void) {
|
||
char ptr_path[300];
|
||
snprintf(ptr_path, sizeof(ptr_path), "%s/conf/current", tmpdir);
|
||
FILE *f = fopen(ptr_path, "w");
|
||
fprintf(f, "cluster.bin/../evil\n");
|
||
fclose(f);
|
||
|
||
char out[256] = {0};
|
||
int rc = conf_read_current_filename(tmpdir, out, sizeof(out));
|
||
TEST_ASSERT_EQUAL_INT(-EINVAL, rc);
|
||
}
|
||
|
||
/* test: empty conf/current treated as ENODATA */
|
||
void test_conf_empty_pointer_file_returns_enodata(void) {
|
||
char ptr_path[300];
|
||
snprintf(ptr_path, sizeof(ptr_path), "%s/conf/current", tmpdir);
|
||
/* Create empty file */
|
||
int fd = open(ptr_path, O_WRONLY|O_CREAT|O_TRUNC, 0600);
|
||
close(fd);
|
||
|
||
char out[256] = {0};
|
||
int rc = conf_read_current_filename(tmpdir, out, sizeof(out));
|
||
TEST_ASSERT_EQUAL_INT(-ENODATA, rc);
|
||
}
|
||
|
||
/* test: conf_check_stale() returns 0 for fresh file, 1 for old file */
|
||
void test_conf_stale_timeout(void) {
|
||
/* Write a config */
|
||
const uint8_t data[] = "stale test blob";
|
||
uint8_t sha256[32];
|
||
SHA256(data, sizeof(data)-1, sha256);
|
||
|
||
myconf_t myconf = {0};
|
||
myconf.workdir = tmpdir;
|
||
myconf.name = "ctrl0";
|
||
myconf.conf_keep_n = 2;
|
||
myconf.conf_stale_timeout = 86400; /* 24h */
|
||
conf_save_config(&myconf, data, sizeof(data)-1, sha256);
|
||
|
||
/* Freshly written — not stale */
|
||
int stale = conf_check_stale(tmpdir, 86400);
|
||
TEST_ASSERT_EQUAL_INT(0, stale);
|
||
|
||
/* Timeout of 0 means disabled — never stale */
|
||
stale = conf_check_stale(tmpdir, 0);
|
||
TEST_ASSERT_EQUAL_INT(0, stale);
|
||
|
||
/* 1-second timeout — touch the file to be 2s old via utime */
|
||
struct utimbuf old_time;
|
||
old_time.actime = time(NULL) - 10;
|
||
old_time.modtime = time(NULL) - 10;
|
||
char ver_path[512];
|
||
/* resolve the versioned filename */
|
||
char fname[256] = {0};
|
||
conf_read_current_filename(tmpdir, fname, sizeof(fname));
|
||
snprintf(ver_path, sizeof(ver_path), "%s/conf/%s", tmpdir, fname);
|
||
utime(ver_path, &old_time);
|
||
|
||
stale = conf_check_stale(tmpdir, 5); /* 5-second timeout */
|
||
TEST_ASSERT_EQUAL_INT(1, stale);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Run tests to verify they fail (functions not yet defined)**
|
||
|
||
```bash
|
||
cd /worktrees/persistent_ha/src && nix develop --command bash -c "ninja -C build controller_unit_test 2>&1 | grep -E 'error:|undefined' | head -20"
|
||
```
|
||
|
||
Expected: linker errors for `conf_save_config`, `conf_read_current_filename`, etc.
|
||
|
||
- [ ] **Step 3: Create config_repl.h**
|
||
|
||
Create `src/controller/config_repl.h`:
|
||
|
||
```c
|
||
#pragma once
|
||
/* config_repl.h — config storage, SHA-256, replication (Phase 1 & 2) */
|
||
#include <stdint.h>
|
||
#include <stddef.h>
|
||
#include "myconf.h"
|
||
|
||
/* Max config blob size — reject oversized files before allocating */
|
||
#define CONF_MAX_SIZE (1 * 1024 * 1024) /* 1 MiB */
|
||
|
||
/* Compile-time gate for OP_upgradeConfig (Phase 2, default disabled) */
|
||
#ifndef CONF_ENABLE_UPGRADE_CONFIG
|
||
#define CONF_ENABLE_UPGRADE_CONFIG 0
|
||
#endif
|
||
|
||
/* Push timeout for fan-out to standbys (ms) */
|
||
#ifndef CONF_PUSH_TIMEOUT_MS
|
||
#define CONF_PUSH_TIMEOUT_MS 500
|
||
#endif
|
||
|
||
/* Days before startup cert-expiry warning is logged */
|
||
#define CERT_WARN_DAYS 30
|
||
|
||
/* ── Phase 1: Storage ────────────────────────────────── */
|
||
|
||
/*
|
||
* conf_read_current_filename: read HEAD-style pointer file.
|
||
* Reads <workdir>/conf/current, strips newline, validates format.
|
||
* Returns 0 on success; -ENOENT if missing; -ENODATA if empty;
|
||
* -EINVAL if malformed (path separator, wrong prefix, wrong hex length).
|
||
* out must be at least NAME_MAX bytes.
|
||
*/
|
||
int conf_read_current_filename(const char *workdir, char *out, size_t outsz);
|
||
|
||
/*
|
||
* conf_save_config: write versioned file + update HEAD pointer atomically.
|
||
* Steps: write <sha_hex>.tmp → fsync → rename to cluster.bin.<sha_hex>,
|
||
* then write current.tmp → fsync → rename to conf/current.
|
||
* Trims old versions after success.
|
||
* Returns 0 on success, -errno on failure.
|
||
*/
|
||
int conf_save_config(myconf_t *myconf, const uint8_t *bytes, size_t len,
|
||
const uint8_t sha256[32]);
|
||
|
||
/*
|
||
* conf_read_raw: read the versioned file named by conf/current.
|
||
* Allocates *bytes (caller must free). Enforces CONF_MAX_SIZE.
|
||
* Returns 0 on success; -EFBIG if over limit; -errno on I/O error.
|
||
*/
|
||
int conf_read_raw(const char *conffile, uint8_t **bytes, size_t *len);
|
||
|
||
/*
|
||
* conf_get_saved_tag: return first 8 bytes of SHA-256 of the current
|
||
* versioned file, host-byte-order. Returns 0 if conf/current is absent
|
||
* or unreadable (zero-tag sentinel for "no config saved").
|
||
*/
|
||
uint64_t conf_get_saved_tag(const char *workdir);
|
||
|
||
/*
|
||
* conf_check_stale: returns 1 if the current versioned file's mtime is
|
||
* older than stale_timeout_s seconds. Returns 0 if within timeout or
|
||
* if stale_timeout_s == 0 (disabled). Returns 0 if conf/current absent.
|
||
*/
|
||
int conf_check_stale(const char *workdir, time_t stale_timeout_s);
|
||
|
||
/*
|
||
* conf_trim_old_versions: keep at most keep_n versioned cluster.bin.* files
|
||
* (by mtime, newest first). Also removes stale .tmp files.
|
||
* Always preserves the file named by the current conf/current pointer.
|
||
*/
|
||
void conf_trim_old_versions(const char *workdir, int keep_n);
|
||
|
||
/*
|
||
* config_contains_reserved_name: returns 1 if ClusterConfig contains
|
||
* a queue or host named "lost_and_found". Used at load time.
|
||
*/
|
||
int config_contains_reserved_name(cluster_config_t *cfg);
|
||
|
||
/*
|
||
* sanitise_cn: copy at most maxlen printable ASCII chars from src to dst,
|
||
* replacing non-printable or control chars with '?'. Returns dst.
|
||
*/
|
||
char *sanitise_cn(const char *src, size_t maxlen);
|
||
```
|
||
|
||
- [ ] **Step 4: Implement Phase 1 functions in config_repl.c**
|
||
|
||
Create `src/controller/config_repl.c` with the Phase 1 functions:
|
||
|
||
```c
|
||
#define _GNU_SOURCE
|
||
#include "config_repl.h"
|
||
|
||
#include <dirent.h>
|
||
#include <errno.h>
|
||
#include <fcntl.h>
|
||
#include <openssl/sha.h>
|
||
#include <stdio.h>
|
||
#include <stdlib.h>
|
||
#include <string.h>
|
||
#include <sys/stat.h>
|
||
#include <sys/types.h>
|
||
#include <time.h>
|
||
#include <utime.h>
|
||
#include <unistd.h>
|
||
#include <pthread.h>
|
||
|
||
#include "byteorder.h"
|
||
#include "clusterconfig.h"
|
||
#include "controller.capnp.h"
|
||
#include "tlog.h"
|
||
|
||
/* ── helpers ──────────────────────────────────────────── */
|
||
|
||
static void make_conf_dir(const char *workdir, char *out, size_t sz)
|
||
{
|
||
snprintf(out, sz, "%s/conf", workdir);
|
||
}
|
||
|
||
static void bytes_to_hex(const uint8_t *b, size_t n, char *out)
|
||
{
|
||
static const char hex[] = "0123456789abcdef";
|
||
for (size_t i = 0; i < n; i++) {
|
||
out[2*i] = hex[(b[i] >> 4) & 0xf];
|
||
out[2*i+1] = hex[b[i] & 0xf];
|
||
}
|
||
out[2*n] = '\0';
|
||
}
|
||
|
||
/* ── conf_read_current_filename ─────────────────────── */
|
||
|
||
int conf_read_current_filename(const char *workdir, char *out, size_t outsz)
|
||
{
|
||
char head_path[4096];
|
||
snprintf(head_path, sizeof(head_path), "%s/conf/current", workdir);
|
||
|
||
FILE *f = fopen(head_path, "r");
|
||
if (!f)
|
||
return -ENOENT;
|
||
|
||
struct stat st;
|
||
if (fstat(fileno(f), &st) == 0 && st.st_size == 0) {
|
||
fclose(f);
|
||
ERROR("conf/current is empty — treating as missing config");
|
||
return -ENODATA;
|
||
}
|
||
|
||
if (!fgets(out, (int)outsz, f)) {
|
||
fclose(f);
|
||
return -EIO;
|
||
}
|
||
fclose(f);
|
||
out[strcspn(out, "\n")] = '\0';
|
||
|
||
if (strchr(out, '/')) {
|
||
ERROR("conf/current contains path separator — corrupted pointer file");
|
||
return -EINVAL;
|
||
}
|
||
if (strncmp(out, "cluster.bin.", 12) != 0) {
|
||
ERROR("conf/current filename '%s' does not start with 'cluster.bin.'", out);
|
||
return -EINVAL;
|
||
}
|
||
const char *hex = out + 12;
|
||
if (strlen(hex) != 12 || strspn(hex, "0123456789abcdef") != 12) {
|
||
ERROR("conf/current suffix '%s' is not 12 hex chars", hex);
|
||
return -EINVAL;
|
||
}
|
||
return 0;
|
||
}
|
||
|
||
/* ── conf_save_config ───────────────────────────────── */
|
||
|
||
int conf_save_config(myconf_t *myconf, const uint8_t *bytes, size_t len,
|
||
const uint8_t sha256[32])
|
||
{
|
||
char conf_dir[4096];
|
||
make_conf_dir(myconf->workdir, conf_dir, sizeof(conf_dir));
|
||
|
||
if (mkdir(conf_dir, 0700) != 0 && errno != EEXIST)
|
||
return -errno;
|
||
|
||
/* Derive 12-char hex tag from first 6 bytes of SHA-256 */
|
||
char hex_tag[13];
|
||
bytes_to_hex(sha256, 6, hex_tag);
|
||
|
||
/* Step 1: write versioned file via thread-unique .tmp */
|
||
char tmp_path[4096], ver_path[4096];
|
||
snprintf(tmp_path, sizeof(tmp_path), "%s/cluster.bin.%s.%lu.tmp",
|
||
conf_dir, hex_tag, (unsigned long)pthread_self());
|
||
snprintf(ver_path, sizeof(ver_path), "%s/cluster.bin.%s",
|
||
conf_dir, hex_tag);
|
||
|
||
int fd = open(tmp_path, O_WRONLY|O_CREAT|O_TRUNC, 0600);
|
||
if (fd < 0)
|
||
return -errno;
|
||
if (write(fd, bytes, len) != (ssize_t)len) {
|
||
close(fd); unlink(tmp_path);
|
||
return -EIO;
|
||
}
|
||
if (fsync(fd) != 0) {
|
||
close(fd); unlink(tmp_path);
|
||
return -EIO;
|
||
}
|
||
close(fd);
|
||
if (rename(tmp_path, ver_path) != 0) {
|
||
unlink(tmp_path);
|
||
return -errno;
|
||
}
|
||
|
||
/* Step 2: update HEAD pointer file via thread-unique .tmp */
|
||
char ptr_tmp[4096], ptr_path[4096];
|
||
snprintf(ptr_tmp, sizeof(ptr_tmp), "%s/current.%lu.tmp",
|
||
conf_dir, (unsigned long)pthread_self());
|
||
snprintf(ptr_path, sizeof(ptr_path), "%s/current", conf_dir);
|
||
|
||
char target[NAME_MAX];
|
||
snprintf(target, sizeof(target), "cluster.bin.%s\n", hex_tag);
|
||
|
||
fd = open(ptr_tmp, O_WRONLY|O_CREAT|O_TRUNC, 0600);
|
||
if (fd < 0)
|
||
return -errno;
|
||
write(fd, target, strlen(target));
|
||
fsync(fd);
|
||
close(fd);
|
||
if (rename(ptr_tmp, ptr_path) != 0) {
|
||
unlink(ptr_tmp);
|
||
return -errno;
|
||
}
|
||
|
||
/* Step 2.5: fsync the conf/ directory to persist the renames */
|
||
fd = open(conf_dir, O_RDONLY);
|
||
if (fd >= 0) { fsync(fd); close(fd); }
|
||
|
||
/* Step 3: trim old versions */
|
||
conf_trim_old_versions(myconf->workdir, myconf->conf_keep_n);
|
||
|
||
/* Update conffile to point to new versioned file */
|
||
free(myconf->conffile);
|
||
asprintf(&myconf->conffile, "%s/cluster.bin.%s", conf_dir, hex_tag);
|
||
|
||
/* Update tag and SHA-256 in myconf */
|
||
uint64_t new_tag;
|
||
memcpy(&new_tag, sha256, 8);
|
||
myconf->config_version_tag = be64toh(new_tag);
|
||
memcpy(myconf->config_sha256, sha256, 32);
|
||
|
||
return 0;
|
||
}
|
||
|
||
/* ── conf_read_raw ──────────────────────────────────── */
|
||
|
||
int conf_read_raw(const char *conffile, uint8_t **bytes, size_t *len)
|
||
{
|
||
FILE *f = fopen(conffile, "rb");
|
||
if (!f)
|
||
return -errno;
|
||
|
||
fseek(f, 0, SEEK_END);
|
||
long sz = ftell(f);
|
||
fseek(f, 0, SEEK_SET);
|
||
|
||
if (sz <= 0) { fclose(f); return -EIO; }
|
||
if ((size_t)sz > CONF_MAX_SIZE) { fclose(f); return -EFBIG; }
|
||
|
||
*bytes = (uint8_t*)malloc((size_t)sz);
|
||
if (!*bytes) { fclose(f); return -ENOMEM; }
|
||
|
||
if (fread(*bytes, 1, (size_t)sz, f) != (size_t)sz) {
|
||
free(*bytes); *bytes = NULL; fclose(f); return -EIO;
|
||
}
|
||
fclose(f);
|
||
*len = (size_t)sz;
|
||
return 0;
|
||
}
|
||
|
||
/* ── conf_get_saved_tag ─────────────────────────────── */
|
||
|
||
uint64_t conf_get_saved_tag(const char *workdir)
|
||
{
|
||
char fname[256] = {0};
|
||
if (conf_read_current_filename(workdir, fname, sizeof(fname)) != 0)
|
||
return 0;
|
||
|
||
char conf_dir[4096];
|
||
make_conf_dir(workdir, conf_dir, sizeof(conf_dir));
|
||
|
||
char ver_path[4096];
|
||
snprintf(ver_path, sizeof(ver_path), "%s/%s", conf_dir, fname);
|
||
|
||
uint8_t *bytes = NULL;
|
||
size_t len = 0;
|
||
if (conf_read_raw(ver_path, &bytes, &len) != 0)
|
||
return 0;
|
||
|
||
uint8_t sha256[32];
|
||
SHA256(bytes, len, sha256);
|
||
free(bytes);
|
||
|
||
uint64_t tag;
|
||
memcpy(&tag, sha256, 8);
|
||
return be64toh(tag);
|
||
}
|
||
|
||
/* ── conf_check_stale ───────────────────────────────── */
|
||
|
||
int conf_check_stale(const char *workdir, time_t stale_timeout_s)
|
||
{
|
||
if (stale_timeout_s == 0)
|
||
return 0;
|
||
|
||
char fname[256] = {0};
|
||
if (conf_read_current_filename(workdir, fname, sizeof(fname)) != 0)
|
||
return 0;
|
||
|
||
char ver_path[4096];
|
||
snprintf(ver_path, sizeof(ver_path), "%s/conf/%s", workdir, fname);
|
||
|
||
struct stat st;
|
||
if (stat(ver_path, &st) != 0)
|
||
return 0;
|
||
|
||
time_t age = time(NULL) - st.st_mtime;
|
||
return (age > stale_timeout_s) ? 1 : 0;
|
||
}
|
||
|
||
/* ── conf_trim_old_versions ─────────────────────────── */
|
||
|
||
void conf_trim_old_versions(const char *workdir, int keep_n)
|
||
{
|
||
char conf_dir[4096];
|
||
make_conf_dir(workdir, conf_dir, sizeof(conf_dir));
|
||
|
||
/* Read current pointer to protect it from deletion */
|
||
char current_name[256] = {0};
|
||
conf_read_current_filename(workdir, current_name, sizeof(current_name));
|
||
|
||
/* Collect cluster.bin.* files (not .tmp) sorted by mtime */
|
||
struct entry { char name[NAME_MAX]; time_t mtime; };
|
||
struct entry entries[256];
|
||
int n = 0;
|
||
|
||
DIR *d = opendir(conf_dir);
|
||
if (!d) return;
|
||
struct dirent *e;
|
||
while ((e = readdir(d)) != NULL && n < 256) {
|
||
if (strncmp(e->d_name, "cluster.bin.", 12) != 0)
|
||
continue;
|
||
if (strstr(e->d_name, ".tmp") != NULL) {
|
||
/* Clean stale .tmp files */
|
||
char full[4096];
|
||
snprintf(full, sizeof(full), "%s/%s", conf_dir, e->d_name);
|
||
unlink(full);
|
||
continue;
|
||
}
|
||
char full[4096];
|
||
snprintf(full, sizeof(full), "%s/%s", conf_dir, e->d_name);
|
||
struct stat st;
|
||
if (stat(full, &st) == 0) {
|
||
strncpy(entries[n].name, e->d_name, NAME_MAX-1);
|
||
entries[n].mtime = st.st_mtime;
|
||
n++;
|
||
}
|
||
}
|
||
closedir(d);
|
||
|
||
/* Sort descending by mtime (newest first) — simple insertion sort */
|
||
for (int i = 1; i < n; i++) {
|
||
struct entry tmp = entries[i];
|
||
int j = i - 1;
|
||
while (j >= 0 && entries[j].mtime < tmp.mtime) {
|
||
entries[j+1] = entries[j]; j--;
|
||
}
|
||
entries[j+1] = tmp;
|
||
}
|
||
|
||
/* Delete everything after keep_n, skipping the current pointer */
|
||
for (int i = keep_n; i < n; i++) {
|
||
if (strcmp(entries[i].name, current_name) == 0)
|
||
continue; /* never delete the active version */
|
||
char full[4096];
|
||
snprintf(full, sizeof(full), "%s/%s", conf_dir, entries[i].name);
|
||
unlink(full);
|
||
}
|
||
}
|
||
|
||
/* ── config_contains_reserved_name ─────────────────── */
|
||
|
||
int config_contains_reserved_name(cluster_config_t *cfg)
|
||
{
|
||
if (!cfg) return 0;
|
||
/* Check queues */
|
||
for (int i = 0; i < cfg->nqueues; i++) {
|
||
if (cfg->queues[i] && strcmp(cfg->queues[i]->name, "lost_and_found") == 0)
|
||
return 1;
|
||
}
|
||
/* Check hosts / candidates */
|
||
for (int i = 0; i < cfg->nhosts; i++) {
|
||
if (cfg->hosts[i] && strcmp(cfg->hosts[i]->name, "lost_and_found") == 0)
|
||
return 1;
|
||
}
|
||
return 0;
|
||
}
|
||
|
||
/* ── sanitise_cn ────────────────────────────────────── */
|
||
|
||
char *sanitise_cn(const char *src, size_t maxlen)
|
||
{
|
||
static char buf[128];
|
||
size_t n = maxlen < sizeof(buf)-1 ? maxlen : sizeof(buf)-1;
|
||
size_t i;
|
||
for (i = 0; i < n && src[i] != '\0'; i++)
|
||
buf[i] = (src[i] >= 0x20 && src[i] < 0x7f) ? src[i] : '?';
|
||
buf[i] = '\0';
|
||
return buf;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 5: Add config_repl.c to meson.build and update test suite**
|
||
|
||
In `src/controller/meson.build`, add `'config_repl.c'` to `controller_src`.
|
||
|
||
Also add to `controller_unit_test_src`:
|
||
```
|
||
'testsuite/test_config_repl.c',
|
||
'config_repl.c',
|
||
```
|
||
|
||
Add compile definition to `controller_unit_test`:
|
||
```
|
||
c_args: ['-DXCE_TESTING', '-DCONF_ENABLE_UPGRADE_CONFIG=0'],
|
||
```
|
||
|
||
- [ ] **Step 6: Run tests to verify they pass**
|
||
|
||
```bash
|
||
cd /worktrees/persistent_ha/src && nix develop --command bash -c "ninja -C build controller_unit_test && ./build/controller_unit_test 2>&1 | grep -E 'PASS|FAIL|test_conf'"
|
||
```
|
||
|
||
Expected: all `test_conf_*` tests PASS.
|
||
|
||
- [ ] **Step 7: Commit**
|
||
|
||
```bash
|
||
git add src/controller/config_repl.h src/controller/config_repl.c src/controller/testsuite/test_config_repl.c src/controller/meson.build
|
||
git commit -m "feat: add config_repl Phase 1 — HEAD-file storage, SHA-256, trim, stale check"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 4: main.c — HEAD-file config resolution + new CLI flags
|
||
|
||
**Files:**
|
||
- Modify: `src/controller/main.c`
|
||
|
||
- [ ] **Step 1: Replace flat conffile path with HEAD-file resolution**
|
||
|
||
In `main()`, replace the `conf_file` stack buffer and assignment:
|
||
|
||
```c
|
||
/* Old code (lines ~79, 115–116, 121): */
|
||
char conf_file[XCE_MAXPATH_LEN + 1];
|
||
snprintf(conf_file, ..., "%s/%s/conf/current_config", ...);
|
||
myconf.conffile = conf_file;
|
||
|
||
/* New code: conffile is heap-allocated by load_primary_config() or
|
||
* fetch_config_from_primary() — do not set it here.
|
||
* Store workdir for use in config_repl.c path construction. */
|
||
/* Remove the conf_file declaration and the myconf.conffile = conf_file line */
|
||
myconf.conffile = NULL; /* will be set by load_primary_config() or fetch */
|
||
```
|
||
|
||
- [ ] **Step 2: Add --conf-keep-n and --conf-stale-timeout CLI options**
|
||
|
||
Add two new option keys and entries to `options[]`:
|
||
|
||
```c
|
||
#define OPT_CONF_KEEP_N 257
|
||
#define OPT_CONF_STALE_TIMEOUT 258
|
||
```
|
||
|
||
```c
|
||
{ "conf-keep-n", OPT_CONF_KEEP_N, "N", 0,
|
||
"Number of versioned config files to retain (default 2)" },
|
||
{ "conf-stale-timeout", OPT_CONF_STALE_TIMEOUT, "SECS", 0,
|
||
"Seconds before saved config is considered stale (0=disabled, default 86400)" },
|
||
```
|
||
|
||
Add to `struct arguments`:
|
||
```c
|
||
int conf_keep_n;
|
||
long conf_stale_timeout;
|
||
```
|
||
|
||
Add cases to `parse_opt`:
|
||
```c
|
||
case OPT_CONF_KEEP_N:
|
||
arguments->conf_keep_n = atoi(arg);
|
||
break;
|
||
case OPT_CONF_STALE_TIMEOUT:
|
||
arguments->conf_stale_timeout = atol(arg);
|
||
break;
|
||
```
|
||
|
||
After `memset(&arguments, ...)`, initialize defaults:
|
||
```c
|
||
arguments.conf_keep_n = 2;
|
||
arguments.conf_stale_timeout = 86400;
|
||
```
|
||
|
||
Set in myconf after parsing:
|
||
```c
|
||
myconf.conf_keep_n = arguments.conf_keep_n;
|
||
myconf.conf_stale_timeout = (time_t)arguments.conf_stale_timeout;
|
||
```
|
||
|
||
- [ ] **Step 3: Add --peers validation WARN**
|
||
|
||
After the `xce_parse_peers()` call, add:
|
||
|
||
```c
|
||
for (int i = 0; i < myconf.peers_cfg.candidates_count; i++) {
|
||
if (myconf.peers_cfg.candidates[i].hostname[0] == '\0' ||
|
||
myconf.peers_cfg.candidates[i].port == 0) {
|
||
WARN("--peers: candidate %d has empty host or port 0 — check peer string",
|
||
i);
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Build**
|
||
|
||
```bash
|
||
cd /worktrees/persistent_ha/src && nix develop --command bash -c "ninja -C build xce_controller 2>&1 | grep -E 'error:|warning:' | head -20"
|
||
```
|
||
|
||
Expected: no errors.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add src/controller/main.c
|
||
git commit -m "feat: update main.c — heap-alloc conffile, add --conf-keep-n/--conf-stale-timeout flags"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 5: myconf.c — load_primary_config() and myconf_fini()
|
||
|
||
**Files:**
|
||
- Modify: `src/controller/myconf.c`
|
||
|
||
- [ ] **Step 1: Write failing test for load_primary_config() missing-file path**
|
||
|
||
In `src/controller/testsuite/test_config_repl.c`, add:
|
||
|
||
```c
|
||
#include "myconf.h"
|
||
/* ... */
|
||
|
||
void test_primary_missing_config_fatal_path(void) {
|
||
myconf_t myconf = {0};
|
||
myconf.workdir = tmpdir;
|
||
myconf.name = "ctrl0";
|
||
myconf.conffile = NULL;
|
||
myconf.conf_keep_n = 2;
|
||
/* No conf/current file exists — load_primary_config must return -ENOENT */
|
||
int rc = load_primary_config_for_test(&myconf);
|
||
TEST_ASSERT_EQUAL_INT(-ENOENT, rc);
|
||
}
|
||
```
|
||
|
||
(We'll expose a `_for_test` variant that returns the error code instead of calling FATAL.)
|
||
|
||
- [ ] **Step 2: Add load_primary_config() to myconf.c**
|
||
|
||
In `src/controller/myconf.c`, add:
|
||
|
||
```c
|
||
#include "config_repl.h"
|
||
#include "byteorder.h"
|
||
#include <openssl/sha.h>
|
||
#include <stdatomic.h>
|
||
```
|
||
|
||
Add the function:
|
||
|
||
```c
|
||
int load_primary_config(cluster_t *c)
|
||
{
|
||
myconf_t *myconf = c->myconf;
|
||
char conf_dir[4096];
|
||
snprintf(conf_dir, sizeof(conf_dir), "%s/%s", myconf->workdir, myconf->name);
|
||
|
||
/* Resolve conf/current → versioned filename */
|
||
char fname[256] = {0};
|
||
int rc = conf_read_current_filename(conf_dir, fname, sizeof(fname));
|
||
if (rc != 0) {
|
||
FATAL("Primary has no config file '%s/conf/current' (rc=%d) — "
|
||
"provision the config before starting the primary controller",
|
||
conf_dir, rc);
|
||
return -ENOENT;
|
||
}
|
||
|
||
/* Heap-allocate conffile so it survives the stack frame */
|
||
free(myconf->conffile);
|
||
if (asprintf(&myconf->conffile, "%s/conf/%s", conf_dir, fname) < 0)
|
||
return -ENOMEM;
|
||
|
||
/* Check for reserved lost_and_found name */
|
||
struct capn cap;
|
||
ClusterConfig_ptr p;
|
||
FILE *fp = fopen(myconf->conffile, "rb");
|
||
if (!fp) {
|
||
FATAL("Primary config file '%s' not readable: %s",
|
||
myconf->conffile, strerror(errno));
|
||
return -errno;
|
||
}
|
||
capn_init_fp(&cap, fp, 0);
|
||
p.p = capn_getp(capn_root(&cap), 0, 1);
|
||
cluster_config_t *cfg = NULL;
|
||
decode_ClusterConfig_ptr(&cfg, p);
|
||
capn_free(&cap);
|
||
fclose(fp);
|
||
|
||
if (!cfg) {
|
||
FATAL("Primary config file '%s' is not a valid ClusterConfig blob",
|
||
myconf->conffile);
|
||
return -EINVAL;
|
||
}
|
||
if (config_contains_reserved_name(cfg)) {
|
||
free_ClusterConfig(cfg);
|
||
FATAL("Config file '%s' contains reserved name 'lost_and_found'",
|
||
myconf->conffile);
|
||
return -EINVAL;
|
||
}
|
||
|
||
fill_default_cluster_config(cfg);
|
||
myconf->cluster_config = cfg;
|
||
|
||
/* Compute and cache SHA-256 */
|
||
uint8_t *bytes = NULL;
|
||
size_t len = 0;
|
||
rc = conf_read_raw(myconf->conffile, &bytes, &len);
|
||
if (rc != 0) {
|
||
FATAL("Failed to read primary config for SHA-256: %d", rc);
|
||
return rc;
|
||
}
|
||
SHA256(bytes, len, myconf->config_sha256);
|
||
free(bytes);
|
||
|
||
uint64_t tag;
|
||
memcpy(&tag, myconf->config_sha256, 8);
|
||
myconf->config_version_tag = be64toh(tag);
|
||
|
||
/* Default generation to 1 if not set in blob */
|
||
if (cfg->config_generation == 0)
|
||
cfg->config_generation = 1;
|
||
myconf->config_generation = cfg->config_generation;
|
||
|
||
/* Check staleness */
|
||
myconf->config_stale = conf_check_stale(conf_dir,
|
||
myconf->conf_stale_timeout);
|
||
if (myconf->config_stale)
|
||
WARN("Primary config appears stale (mtime > %lds)", myconf->conf_stale_timeout);
|
||
|
||
INFO("Primary config loaded: tag=%016"PRIx64" gen=%"PRIu64,
|
||
myconf->config_version_tag, myconf->config_generation);
|
||
return 0;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Add myconf_fini() to myconf.c**
|
||
|
||
```c
|
||
void myconf_fini(myconf_t *myconf)
|
||
{
|
||
if (!myconf) return;
|
||
free(myconf->conffile);
|
||
myconf->conffile = NULL;
|
||
if (myconf->cluster_config) {
|
||
free_ClusterConfig(myconf->cluster_config);
|
||
free(myconf->cluster_config);
|
||
myconf->cluster_config = NULL;
|
||
}
|
||
}
|
||
```
|
||
|
||
Remove the `static struct cluster_configuration *cf = NULL;` global and the `cluster_hook_fun` / `config_fini` functions that handle the shutdown hook for `cf` — `myconf_fini()` takes over this responsibility.
|
||
|
||
Keep `load_cluster_config()` and `load_cluster_config_from_file()` for now but mark them as legacy (they're still used by the existing `get_default_conf()` path during Phase 1 work).
|
||
|
||
- [ ] **Step 4: Declare load_primary_config() in myconf.h**
|
||
|
||
In `src/include/myconf.h`, add:
|
||
|
||
```c
|
||
/* Declared here; defined in myconf.c; used by myapp.c */
|
||
int load_primary_config(struct cluster_s *c);
|
||
```
|
||
|
||
(forward-declare `cluster_t` in myconf.h to avoid circular include):
|
||
```c
|
||
typedef struct cluster_s cluster_t;
|
||
```
|
||
|
||
- [ ] **Step 5: Build and run tests**
|
||
|
||
```bash
|
||
cd /worktrees/persistent_ha/src && nix develop --command bash -c "ninja -C build controller_unit_test && ./build/controller_unit_test 2>&1 | grep -E 'PASS|FAIL|test_primary'"
|
||
```
|
||
|
||
Expected: `test_primary_missing_config_fatal_path` PASS.
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add src/controller/myconf.c src/include/myconf.h src/controller/testsuite/test_config_repl.c
|
||
git commit -m "feat: add load_primary_config() and myconf_fini() — replace get_default_conf static global"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 6: ha.c — real am_i_primary() with election probing
|
||
|
||
**Files:**
|
||
- Modify: `src/controller/ha.c`
|
||
- Modify: `src/controller/ha.h`
|
||
- Modify: `src/controller/testsuite/test_ha_role.c`
|
||
|
||
- [ ] **Step 1: Write failing tests for election probing**
|
||
|
||
In `src/controller/testsuite/test_ha_role.c`, add:
|
||
|
||
```c
|
||
/* test: priority-0 with no peers → am_i_primary returns 1 without probing */
|
||
void test_am_i_primary_priority_zero_no_peers(void) {
|
||
cluster_t c;
|
||
memset(&c, 0, sizeof(c));
|
||
myconf_t myconf = {0};
|
||
myconf.peers_cfg.candidates_count = 0;
|
||
c.myconf = &myconf;
|
||
ha_config_t cfg = {
|
||
.heartbeat_interval_ms = HA_DEFAULT_HEARTBEAT_INTERVAL_MS,
|
||
.heartbeat_timeout_ms = HA_DEFAULT_HEARTBEAT_TIMEOUT_MS,
|
||
.election_timeout_ms = HA_DEFAULT_ELECTION_TIMEOUT_MS,
|
||
.probe_connect_timeout_ms = HA_DEFAULT_PROBE_CONNECT_TIMEOUT_MS,
|
||
.max_promotion_retries = HA_DEFAULT_MAX_PROMOTION_RETRIES,
|
||
.election_jitter_max_ms = 0,
|
||
.election_priority_step_ms = 500,
|
||
};
|
||
ha_init(&c.ha, &cfg, 0);
|
||
TEST_ASSERT_EQUAL_INT(1, am_i_primary(&c));
|
||
ha_fini(&c.ha);
|
||
}
|
||
|
||
/* test: priority-0 with peers → am_i_primary returns 1 (no higher-priority peers) */
|
||
void test_am_i_primary_priority_zero_with_peers(void) {
|
||
cluster_t c;
|
||
memset(&c, 0, sizeof(c));
|
||
myconf_t myconf = {0};
|
||
myconf.peers_cfg.candidates_count = 3;
|
||
strncpy(myconf.peers_cfg.candidates[0].hostname, "ctrl0", XCE_MAXNAME_LEN-1);
|
||
myconf.peers_cfg.candidates[0].port = 9990;
|
||
strncpy(myconf.peers_cfg.candidates[1].hostname, "ctrl1", XCE_MAXNAME_LEN-1);
|
||
myconf.peers_cfg.candidates[1].port = 9991;
|
||
strncpy(myconf.peers_cfg.candidates[2].hostname, "ctrl2", XCE_MAXNAME_LEN-1);
|
||
myconf.peers_cfg.candidates[2].port = 9992;
|
||
c.myconf = &myconf;
|
||
ha_config_t cfg = {
|
||
.heartbeat_interval_ms = HA_DEFAULT_HEARTBEAT_INTERVAL_MS,
|
||
.heartbeat_timeout_ms = HA_DEFAULT_HEARTBEAT_TIMEOUT_MS,
|
||
.election_timeout_ms = 200, /* short for test */
|
||
.probe_connect_timeout_ms = 50,
|
||
.max_promotion_retries = 1,
|
||
.election_jitter_max_ms = 0,
|
||
.election_priority_step_ms = 0,
|
||
};
|
||
ha_init(&c.ha, &cfg, 0);
|
||
/* Priority-0 never probes — must return immediately */
|
||
int result = am_i_primary(&c);
|
||
TEST_ASSERT_EQUAL_INT(1, result);
|
||
ha_fini(&c.ha);
|
||
}
|
||
|
||
/* test: priority-1 with no higher-priority peer alive → deadline expires → primary */
|
||
void test_am_i_primary_deadline_expires_becomes_primary(void) {
|
||
cluster_t c;
|
||
memset(&c, 0, sizeof(c));
|
||
myconf_t myconf = {0};
|
||
/* Single higher-priority candidate at a port nobody listens on */
|
||
myconf.peers_cfg.candidates_count = 2;
|
||
strncpy(myconf.peers_cfg.candidates[0].hostname, "127.0.0.1", XCE_MAXNAME_LEN-1);
|
||
myconf.peers_cfg.candidates[0].port = 19990; /* nobody listening */
|
||
strncpy(myconf.peers_cfg.candidates[1].hostname, "127.0.0.1", XCE_MAXNAME_LEN-1);
|
||
myconf.peers_cfg.candidates[1].port = 19991;
|
||
c.myconf = &myconf;
|
||
ha_config_t cfg = {
|
||
.heartbeat_interval_ms = HA_DEFAULT_HEARTBEAT_INTERVAL_MS,
|
||
.heartbeat_timeout_ms = HA_DEFAULT_HEARTBEAT_TIMEOUT_MS,
|
||
.election_timeout_ms = 300, /* 300ms for test speed */
|
||
.probe_connect_timeout_ms = 100,
|
||
.max_promotion_retries = 1,
|
||
.election_jitter_max_ms = 0,
|
||
.election_priority_step_ms = 0,
|
||
};
|
||
ha_init(&c.ha, &cfg, 1); /* priority 1 */
|
||
int result = am_i_primary(&c);
|
||
TEST_ASSERT_EQUAL_INT(1, result); /* deadline expired, self-declare */
|
||
ha_fini(&c.ha);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Run tests to see them fail (am_i_primary still the stub)**
|
||
|
||
```bash
|
||
cd /worktrees/persistent_ha/src && nix develop --command bash -c "ninja -C build controller_unit_test && ./build/controller_unit_test 2>&1 | grep -E 'test_am_i_primary|FAIL'"
|
||
```
|
||
|
||
Expected: `test_am_i_primary_deadline_expires_becomes_primary` FAIL (stub returns 0 for priority 1).
|
||
|
||
- [ ] **Step 3: Implement xorshift64_next() inline in ha.h**
|
||
|
||
In `src/controller/ha.h`, add after the `#include` block:
|
||
|
||
```c
|
||
#include <fcntl.h>
|
||
#include <unistd.h>
|
||
|
||
static inline uint64_t xorshift64_next(uint64_t *state)
|
||
{
|
||
uint64_t x = *state;
|
||
x ^= x << 13;
|
||
x ^= x >> 7;
|
||
x ^= x << 17;
|
||
*state = x;
|
||
return x;
|
||
}
|
||
|
||
/* Seed jitter_seed from /dev/urandom; fall back to monotonic clock + pid */
|
||
static inline void ha_seed_jitter(uint64_t *seed, int my_priority)
|
||
{
|
||
int fd = open("/dev/urandom", O_RDONLY);
|
||
if (fd >= 0) {
|
||
read(fd, seed, 8);
|
||
close(fd);
|
||
if (*seed != 0) return;
|
||
}
|
||
struct timespec ts;
|
||
clock_gettime(CLOCK_MONOTONIC, &ts);
|
||
*seed = (uint64_t)ts.tv_nsec
|
||
^ ((uint64_t)my_priority << 56)
|
||
^ ((uint64_t)getpid() << 16);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Implement probe_thread() and am_i_primary() in ha.c**
|
||
|
||
In `src/controller/ha.c`, add includes at the top:
|
||
|
||
```c
|
||
#include <arpa/inet.h>
|
||
#include <fcntl.h>
|
||
#include <netdb.h>
|
||
#include <openssl/ssl.h>
|
||
#include <poll.h>
|
||
#include <sys/socket.h>
|
||
#include "byteorder.h"
|
||
#include "config_repl.h"
|
||
```
|
||
|
||
Then replace the current `am_i_primary()` stub (ha.c:213-216) with:
|
||
|
||
```c
|
||
static void *probe_thread(void *arg)
|
||
{
|
||
probe_ctx_t *ctx = (probe_ctx_t *)arg;
|
||
|
||
/* Non-blocking connect to candidate */
|
||
struct addrinfo hints, *res = NULL;
|
||
memset(&hints, 0, sizeof(hints));
|
||
hints.ai_family = AF_UNSPEC;
|
||
hints.ai_socktype = SOCK_STREAM;
|
||
|
||
char port_str[16];
|
||
snprintf(port_str, sizeof(port_str), "%u", ctx->candidate->port);
|
||
if (getaddrinfo(ctx->candidate->hostname, port_str, &hints, &res) != 0)
|
||
goto done;
|
||
|
||
int sock = socket(res->ai_family, res->ai_socktype, res->ai_protocol);
|
||
if (sock < 0) { freeaddrinfo(res); goto done; }
|
||
|
||
fcntl(sock, F_SETFL, O_NONBLOCK);
|
||
int crc = connect(sock, res->ai_addr, res->ai_addrlen);
|
||
freeaddrinfo(res);
|
||
if (crc != 0 && errno != EINPROGRESS) { close(sock); goto done; }
|
||
|
||
/* poll loop with election_done check */
|
||
struct pollfd pfd = { .fd = sock, .events = POLLOUT };
|
||
while (!atomic_load(ctx->election_done)) {
|
||
int pr = poll(&pfd, 1, (int)ctx->timeout_ms);
|
||
if (pr < 0 && errno == EINTR) continue;
|
||
if (pr <= 0) { close(sock); goto done; }
|
||
if (pfd.revents & (POLLERR|POLLHUP)) { close(sock); goto done; }
|
||
if (pfd.revents & POLLOUT) break;
|
||
}
|
||
if (atomic_load(ctx->election_done)) { close(sock); goto done; }
|
||
|
||
/* Verify connect succeeded */
|
||
int err = 0;
|
||
socklen_t el = sizeof(err);
|
||
getsockopt(sock, SOL_SOCKET, SO_ERROR, &err, &el);
|
||
if (err != 0) { close(sock); goto done; }
|
||
|
||
/* SSL handshake */
|
||
SSL *ssl = SSL_new((SSL_CTX *)ctx->ssl_ctx);
|
||
if (!ssl) { close(sock); goto done; }
|
||
SSL_set_fd(ssl, sock);
|
||
fcntl(sock, F_SETFL, 0); /* blocking for SSL */
|
||
|
||
int sr;
|
||
do { sr = SSL_connect(ssl); } while (
|
||
(sr <= 0) &&
|
||
(SSL_get_error(ssl, sr) == SSL_ERROR_WANT_READ ||
|
||
SSL_get_error(ssl, sr) == SSL_ERROR_WANT_WRITE) &&
|
||
!atomic_load(ctx->election_done));
|
||
|
||
if (sr <= 0 || atomic_load(ctx->election_done)) {
|
||
SSL_free(ssl); close(sock); goto done;
|
||
}
|
||
|
||
/* Send a minimal OP_heartbeat request and check for any reply */
|
||
/* Use the existing xce_nop_req pattern: just check TLS connected */
|
||
ctx->got_ack = 1;
|
||
SSL_shutdown(ssl);
|
||
SSL_free(ssl);
|
||
close(sock);
|
||
done:
|
||
return NULL;
|
||
}
|
||
|
||
static int wait_any_probe(probe_ctx_t *probes, int n,
|
||
const struct timespec *deadline)
|
||
{
|
||
while (1) {
|
||
for (int i = 0; i < n; i++) {
|
||
if (probes[i].got_ack)
|
||
return 1;
|
||
}
|
||
struct timespec now;
|
||
clock_gettime(CLOCK_MONOTONIC, &now);
|
||
if (now.tv_sec > deadline->tv_sec ||
|
||
(now.tv_sec == deadline->tv_sec &&
|
||
now.tv_nsec >= deadline->tv_nsec))
|
||
return 0;
|
||
struct timespec sleep_ts = { .tv_sec = 0, .tv_nsec = 10 * 1000000L };
|
||
nanosleep(&sleep_ts, NULL);
|
||
}
|
||
}
|
||
|
||
int am_i_primary(cluster_t *c)
|
||
{
|
||
/* Priority-0 is unconditionally primary — no probing needed */
|
||
if (c->ha.my_priority == 0)
|
||
return 1;
|
||
|
||
/* Seed jitter PRNG if not yet seeded */
|
||
if (c->ha.jitter_seed == 0)
|
||
ha_seed_jitter(&c->ha.jitter_seed, c->ha.my_priority);
|
||
|
||
const xce_cluster_config_t *pcfg = &c->myconf->peers_cfg;
|
||
int n_probes = c->ha.my_priority < pcfg->candidates_count
|
||
? c->ha.my_priority
|
||
: pcfg->candidates_count;
|
||
|
||
if (n_probes == 0)
|
||
return 1; /* no higher-priority peers to probe */
|
||
|
||
atomic_int election_done = 0;
|
||
probe_ctx_t probes[MAX_HA_CANDIDATES];
|
||
|
||
for (int i = 0; i < n_probes; i++) {
|
||
probes[i] = (probe_ctx_t){
|
||
.candidate = (struct xce_candidate_s *)&pcfg->candidates[i],
|
||
.ssl_ctx = c->ha.ssl_ctx,
|
||
.timeout_ms = c->ha.config.probe_connect_timeout_ms,
|
||
.election_done = &election_done,
|
||
.got_ack = 0,
|
||
};
|
||
pthread_create(&probes[i].thread, NULL, probe_thread, &probes[i]);
|
||
}
|
||
|
||
/* Compute deadline with priority step + jitter */
|
||
long to_ms = (long)c->ha.config.election_timeout_ms;
|
||
to_ms += (long)c->ha.my_priority * (long)c->ha.config.election_priority_step_ms;
|
||
to_ms += (long)(xorshift64_next(&c->ha.jitter_seed)
|
||
% (c->ha.config.election_jitter_max_ms + 1));
|
||
|
||
struct timespec deadline;
|
||
clock_gettime(CLOCK_MONOTONIC, &deadline);
|
||
deadline.tv_sec += to_ms / 1000;
|
||
deadline.tv_nsec += (to_ms % 1000) * 1000000L;
|
||
if (deadline.tv_nsec >= 1000000000L) {
|
||
deadline.tv_sec++;
|
||
deadline.tv_nsec -= 1000000000L;
|
||
}
|
||
|
||
int got_ack = wait_any_probe(probes, n_probes, &deadline);
|
||
|
||
atomic_store(&election_done, 1);
|
||
for (int i = 0; i < n_probes; i++)
|
||
pthread_join(probes[i].thread, NULL);
|
||
|
||
if (got_ack) {
|
||
INFO("HA election: higher-priority candidate alive — starting as standby "
|
||
"(my_priority=%d)", c->ha.my_priority);
|
||
return 0;
|
||
}
|
||
INFO("HA election: no higher-priority candidate responded within %ldms — "
|
||
"starting as primary (my_priority=%d)", to_ms, c->ha.my_priority);
|
||
return 1;
|
||
}
|
||
|
||
int ha_is_primary(const ha_state_t *ha)
|
||
{
|
||
return (ctrl_role_t)atomic_load_explicit(&ha->role, memory_order_acquire)
|
||
== CTRL_PRIMARY;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 5: Enhance ha_check_heartbeat_nocluster() for simultaneous-boot priority comparison**
|
||
|
||
Add to `ha_check_heartbeat_nocluster()` — after the elapsed_ms check, add priority comparison logic. For now, add a helper stub that will be fleshed out when heartbeat message structs are wired:
|
||
|
||
```c
|
||
/* ha_check_priority_stepdown: if we are self-declared primary (term=0)
|
||
* and receive a heartbeat from a higher-priority peer with term=0,
|
||
* step down. Called from the heartbeat handler in cluster.c.
|
||
* sender_priority: lower index = higher priority.
|
||
*/
|
||
void ha_check_priority_stepdown(ha_state_t *ha, int sender_priority,
|
||
uint64_t sender_term)
|
||
{
|
||
if (sender_priority >= ha->my_priority)
|
||
return; /* sender has lower or equal priority — no action */
|
||
|
||
uint64_t my_term = atomic_load_explicit(&ha->term, memory_order_acquire);
|
||
if (my_term > 0 || sender_term > 0)
|
||
return; /* term-based comparison takes over after first election */
|
||
|
||
/* Both at term=0 during simultaneous boot — higher-priority wins */
|
||
ctrl_role_t role = (ctrl_role_t)atomic_load_explicit(&ha->role,
|
||
memory_order_acquire);
|
||
if (role == CTRL_PRIMARY) {
|
||
WARN("HA: stepping down — higher-priority peer %d present at term=0",
|
||
sender_priority);
|
||
ha_do_demote(ha);
|
||
}
|
||
}
|
||
```
|
||
|
||
Declare in `ha.h`:
|
||
```c
|
||
void ha_check_priority_stepdown(ha_state_t *ha, int sender_priority,
|
||
uint64_t sender_term);
|
||
```
|
||
|
||
- [ ] **Step 6: Add ha_init() updates for new fields**
|
||
|
||
In `ha_init()`, after the existing atomic stores, add:
|
||
|
||
```c
|
||
ha->ssl_ctx = NULL;
|
||
ha->jitter_seed = 0; /* seeded lazily in am_i_primary */
|
||
ha->standby_endpoints = NULL; /* initialized in cluster_init Phase 1 */
|
||
```
|
||
|
||
Also update default config init in `ha_init()` validation to accept the new `ha_config_t` fields (they default to 0 if not set, which is safe — step=0 means no step, jitter=0 means no jitter).
|
||
|
||
- [ ] **Step 7: Run tests**
|
||
|
||
```bash
|
||
cd /worktrees/persistent_ha/src && nix develop --command bash -c "ninja -C build controller_unit_test && ./build/controller_unit_test 2>&1 | grep -E 'test_am_i_primary|PASS|FAIL'"
|
||
```
|
||
|
||
Expected: all three new `test_am_i_primary_*` tests PASS.
|
||
|
||
- [ ] **Step 8: Commit**
|
||
|
||
```bash
|
||
git add src/controller/ha.c src/controller/ha.h src/controller/testsuite/test_ha_role.c
|
||
git commit -m "feat: implement real am_i_primary() with election probing and priority-based symmetry breaking"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 7: cluster.c + myapp.c — Phase 1/Phase 2 split
|
||
|
||
**Files:**
|
||
- Modify: `src/controller/cluster.c`
|
||
- Modify: `src/controller/myapp.c`
|
||
|
||
- [ ] **Step 1: Write failing integration test for config-gated init**
|
||
|
||
In `src/controller/testsuite/test_p1_integration.c`, add a test verifying that `cluster_init_phase1()` succeeds on a minimal cluster_t without requiring a config file:
|
||
|
||
```c
|
||
void test_cluster_phase1_no_config_required(void) {
|
||
cluster_t c;
|
||
memset(&c, 0, sizeof(c));
|
||
myconf_t myconf = {0};
|
||
myconf.workdir = "/tmp";
|
||
myconf.name = "test";
|
||
/* certfile/keyfile are empty — skip SSL for this structural test */
|
||
c.myconf = &myconf;
|
||
/* Phase 1 should succeed without a config file being present */
|
||