From 30c51ea147d4c99da0d47f4270fe96528ebfa752 Mon Sep 17 00:00:00 2001 From: "lei.chen" Date: Wed, 28 Oct 2026 11:17:36 +0000 Subject: [PATCH] external-license-resources 3.3b: elim load-index collector in xcecd Per-host user-script load indices (LSF elim model): a config-declared script collector (external_resources: [{name, command, interval}]) in xcecd registers getters into resource_sync's load-index registry, so user-defined dynamic resources flow the existing resval chain (collect -> xcecli_update_resvals -> controller -> scheduler) without any new daemon. - elim.c: libcyaml config parse (fail-loud at load), posix_spawn script runner with kill-watchdog timeout (mirrors esd_script.c, no fork in xcecd), first-line output parse (bare number or " "), per-source interval cache (getter serves last value until next_run), fail-open keep-last-value on script failure - resource_sync.c: fix two init-order traps exposed by external registration -- init_builtin_load_indices no longer re-callocs a populated g_load_indices array (clobbered entries -> strcmp garbage -> segfault), and register self-allocates when called before the first collect (capacity-0 trap); new public resource_sync_ensure_builtin_load_indices() so elim_register_sources can register before lazy builtin init and builtin-name shadowing is a clean -EEXIST at startup - main.c: xcecd -c/--config option (elim config file); ownership main.c -> xcecd_init -> resource_sync registration, with rollback on init failure and cleanup in xcecd_cleanup - tests: 18-case white-box elim_test (config/parse/getter-cache/ register/unregister/script-runner incl. timeout no-zombie), plus stubs for the startup-order test - t1/t2/t3 E2E: per-host dynres1 values visible in CLI (11/22/33 from each node's own script); job with rusage[dynres1=15] RUNs on t2 (22 >= 15, t1=11 correctly skipped), RUN->DONE; over-ask rusage[dynres1=50] stays PEND; second saturating job overflows to t3; kill frees the reservation and a new job re-fills t2 Note: rusage clauses use square-bracket syntax rusage[...]; the LSF-style paren form rusage(...) is parsed as a select expression and rejected by the select grammar (observed live, pre-existing). Co-Authored-By: Claude Code --- src/worker/include/elim.h | 83 +++ src/worker/include/resource_sync.h | 6 + src/worker/include/xcecd.h | 9 + src/worker/meson.build | 2 + src/worker/src/elim.c | 656 ++++++++++++++++++ src/worker/src/main.c | 48 +- src/worker/src/resource_sync.c | 60 +- src/worker/src/xcecd.c | 20 + src/worker/testsuite/meson.build | 17 + src/worker/testsuite/test_elim.c | 566 +++++++++++++++ .../testsuite/test_xcecd_startup_order.c | 11 + 11 files changed, 1471 insertions(+), 7 deletions(-) create mode 100644 src/worker/include/elim.h create mode 100644 src/worker/src/elim.c create mode 100644 src/worker/testsuite/test_elim.c diff --git a/src/worker/include/elim.h b/src/worker/include/elim.h new file mode 100644 index 00000000..bf3e5aef --- /dev/null +++ b/src/worker/include/elim.h @@ -0,0 +1,83 @@ +#ifndef ELIM_H +#define ELIM_H + +#include +#include + +/* + * elim -- the LSF-elim-compatible external resource collector for xcecd + * (external-license-resources design §3.3b). Each configured source names a + * command run on this host (posix_spawn, watchdog kill at + * elim_script_timeout_ms); the command prints " " (elim + * format) or a bare number on stdout, and the value registers as a custom + * load index on resource_sync's registry -- flowing the live per-host + * report chain (collect_load_indices -> xcecd_send_load_indices -> + * controller update_val_res -> scheduler RESVALS_DELTA) with zero + * downstream changes. + * + * Per-host semantics by design: an elim script answers "how much does THIS + * host have"; cluster-global pools (license servers) belong to xceesd. + */ + +typedef struct elim_source_s { + char* name; /* resource name the script reports */ + char* command; /* shell command line (run via /bin/sh -c) */ + int interval; /* seconds between re-runs; 0 = every collect */ +} elim_source_t; + +typedef struct elim_config_s { + int nsrc; + elim_source_t* src; /* array of nsrc sources */ +} elim_config_t; + +/* + * Parse the elim section of the worker config file: + * + * external_resources: + * - name: dynres1 + * command: /opt/xce/scripts/report-dynres1.sh + * interval: 30 + * + * Returns 0, -ENOENT (file missing), -EINVAL (bad shape: a source without + * name or command can never report -- fail loudly at load, not silently at + * runtime), -ENOMEM. + */ +extern int elim_config_load(const char* path, elim_config_t* cfg_out); +extern int elim_config_load_path(const char* dir, const char* name, + elim_config_t* cfg_out); +extern void elim_config_fini(elim_config_t* cfg); + +/* + * Register every configured source as a custom (non-builtin) load index on + * resource_sync's registry. Returns 0, or -EEXIST if a source's name is + * already taken (another source, or one of the 11 builtins -- a user script + * must not shadow "mem"/"r15s"/...), or -EINVAL/-ENOMEM. Registration + * happens at startup, before the sync thread's first collect -- the + * registry self-initializes for that (the pre-phase "capacity reached (0)" + * trap). + */ +extern int elim_register_sources(const elim_config_t* cfg); +extern void elim_unregister_sources(const elim_config_t* cfg); + +/* + * Run one collection command and read its stdout. Returns 0 with `out` + * NUL-terminated, -ETIMEDOUT when the watchdog killed the child, + * -ENODATA on empty output, -EINVAL/-errno on spawn/read failure. + * The child gets an empty signal mask and default dispositions; stderr is + * /dev/null. Never blocks past elim_script_timeout_ms. + */ +extern int elim_script_collect(const char* command, char* out, size_t outsz); + +/* + * Parse an elim-format collection output: the first non-empty line carries + * " " or a bare number; leading/trailing whitespace ignored. + * Returns the value, or NAN on garbage/empty (caller owns the fail-open + * policy). + */ +extern double elim_parse_script_output(const char* out); + +/* Watchdog, milliseconds a collection command may run before it is killed + * (overridable from tests / future CLI). */ +extern int elim_script_timeout_ms; + +#endif // ELIM_H diff --git a/src/worker/include/resource_sync.h b/src/worker/include/resource_sync.h index e271b11c..f43e926d 100644 --- a/src/worker/include/resource_sync.h +++ b/src/worker/include/resource_sync.h @@ -19,4 +19,10 @@ bool resource_sync_register_load_index(const char* name, int valtype, bool is_builtin); bool resource_sync_unregister_load_index(const char* name); +/* Ensure the registry array exists and the 11 builtin load indices are in + * it -- for external registrars (elim) that run before the sync thread's + * first collect would lazily populate them, so their name-collision checks + * see the builtins. */ +void resource_sync_ensure_builtin_load_indices(void); + #endif // RESOURCE_SYNC_H \ No newline at end of file diff --git a/src/worker/include/xcecd.h b/src/worker/include/xcecd.h index 480fcba1..83d6d007 100644 --- a/src/worker/include/xcecd.h +++ b/src/worker/include/xcecd.h @@ -2,6 +2,7 @@ #define XCECD_H #include "controller_connector.h" +#include "elim.h" #include "hardware_monitor.h" #include "job_information.h" #include "node_information.h" @@ -63,6 +64,11 @@ typedef struct { * xcecd_start() already ran. candidates_count == 0 means no --peers was * given (single-host default). */ xce_cluster_config_t cluster_cfg; + /* elim sources from the -c config file's external_resources section + * (design §3.3b). Loaded in main.c (elim_config_load); ownership moves + * to xcecd_ctx by xcecd_init(), which registers the sources with + * resource_sync at startup. */ + elim_config_t elim; } xcecd_config_t; typedef struct { @@ -88,6 +94,9 @@ typedef struct { char* log_dir; char* db_path; char* virtual_name; + /* elim collectors (design §3.3b): sources loaded from the -c config + * file, registered with resource_sync by xcecd_init(). */ + elim_config_t elim; /* Copied from xcecd_config_t by xcecd_init(); consumed by * controller_connector_init() (xcecd_start()) to populate * cc_ctx->cluster_cfg BEFORE controller_connector_start() fires the diff --git a/src/worker/meson.build b/src/worker/meson.build index 4d97789b..4245a853 100644 --- a/src/worker/meson.build +++ b/src/worker/meson.build @@ -16,6 +16,7 @@ xcecd_worker_srcs = [ 'src/xcecd.c', 'src/connection_handler.c', 'src/resource_sync.c', + 'src/elim.c', 'src/node_information.c', 'src/thread_pool.c', 'src/hardware_monitor.c', @@ -95,6 +96,7 @@ xcecd_srcs = [ 'src/connection_handler.c', 'src/controller_connector.c', 'src/resource_sync.c', + 'src/elim.c', 'src/xcecd_worker_utils.c', 'src/xcecd_resync.c', 'src/hardware_monitor.c', diff --git a/src/worker/src/elim.c b/src/worker/src/elim.c new file mode 100644 index 00000000..b15400fb --- /dev/null +++ b/src/worker/src/elim.c @@ -0,0 +1,656 @@ +/* elim.c -- the LSF-elim-compatible external resource collector for xcecd + * (external-license-resources design §3.3b). + * + * The worker already owns half the elim model as live code: resource_sync.c + * keeps a load-index registry (11 builtins shaped after LSF's load index + * name set) whose values flow the per-host report chain every 15 ticks -- + * collect_load_indices() -> xcecd_send_load_indices() -> controller + * update_val_res (non-SHARED lands in w->host.resvals) -> scheduler + * RESVALS_DELTA -> sched_check_host_satisfy_. What was missing is the + * ENTRY: a mechanism for a user to register their own index. This module is + * that entry -- config-driven script collectors registered as custom + * (non-builtin) load indices at startup. + * + * posix_spawn(), not fork(): xcecd is multi-threaded (no-fork rule, + * .kiro/steering/tech.md "Process Creation") and a forked child that + * allocated or logged before exec could deadlock on a lock another thread + * held. The spawn/watchdog shape mirrors esd_script.c (the xceesd + * collector, same phase's global leg): pipe, poll(2) in 100ms slices, + * kill(SIGKILL) at elim_script_timeout_ms, waitpid reaps -- a hung script + * must not stall the sync thread. + */ +#define _GNU_SOURCE + +#include "elim.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include "resource_sync.h" +#include "xcelog.h" + +/* Watchdog, milliseconds of wall clock a collection command may run before + * it is killed (design §3.3b: 脚本超时看门狗 kill). */ +int elim_script_timeout_ms = 10 * 1000; + +/* ── config parse (libcyaml, esd_config.c pattern) ────────────────────── */ + +typedef struct yaml_elim_source_s { + char* name; + char* command; + long interval; /* 0 = every collect tick */ +} yaml_elim_source_t; + +typedef struct yaml_elim_config_s { + yaml_elim_source_t* external_resources; + unsigned external_resources_count; +} yaml_elim_config_t; + +static const cyaml_schema_field_t elim_source_fields_schema[] = { + CYAML_FIELD_STRING_PTR("name", CYAML_FLAG_POINTER, yaml_elim_source_t, + name, 1, CYAML_UNLIMITED), + CYAML_FIELD_STRING_PTR("command", CYAML_FLAG_POINTER, yaml_elim_source_t, + command, 1, CYAML_UNLIMITED), + CYAML_FIELD_INT("interval", CYAML_FLAG_OPTIONAL, yaml_elim_source_t, + interval), + CYAML_FIELD_END +}; + +static const cyaml_schema_value_t elim_source_schema = { + CYAML_VALUE_MAPPING(CYAML_FLAG_DEFAULT, yaml_elim_source_t, + elim_source_fields_schema) +}; + +static const cyaml_schema_field_t elim_config_fields_schema[] = { + CYAML_FIELD_SEQUENCE("external_resources", CYAML_FLAG_POINTER, + yaml_elim_config_t, external_resources, + &elim_source_schema, 0, CYAML_UNLIMITED), + CYAML_FIELD_END +}; + +static const cyaml_schema_value_t elim_config_schema = { + CYAML_VALUE_MAPPING(CYAML_FLAG_POINTER, yaml_elim_config_t, + elim_config_fields_schema) +}; + +int elim_config_load_path(const char* dir, const char* name, + elim_config_t* cfg_out) +{ + static cyaml_config_t cyaml_cfg = { + .mem_fn = cyaml_mem, + .log_level = CYAML_LOG_WARNING, + .flags = CYAML_CFG_IGNORE_UNKNOWN_KEYS, + }; + + yaml_elim_config_t* ycfg = NULL; + cyaml_err_t err; + char* path = NULL; + elim_config_t cfg; + int i; + int rc; + + if ((dir == NULL) || (name == NULL) || (cfg_out == NULL)) { + return -EINVAL; + } + + memset(cfg_out, 0, sizeof(*cfg_out)); + memset(&cfg, 0, sizeof(cfg)); + + if (asprintf(&path, "%s/%s", dir, name) < 0) { + return -ENOMEM; + } + + err = cyaml_load_file(path, &cyaml_cfg, &elim_config_schema, + (void**)&ycfg, NULL); + free(path); + if (err != CYAML_OK) { + /* Map file-not-found apart from bad-file so the startup log can + * say which (esd_config.c's probe). */ + char probe[512]; + FILE* f; + + snprintf(probe, sizeof(probe), "%s/%s", dir, name); + f = fopen(probe, "r"); + if (f == NULL) { + return -ENOENT; + } + fclose(f); + return -EINVAL; + } + + if (ycfg == NULL) { + return -EINVAL; + } + + cfg.nsrc = (int)ycfg->external_resources_count; + cfg.src = NULL; + + if (cfg.nsrc > 0) { + cfg.src = (elim_source_t*)calloc((size_t)cfg.nsrc, + sizeof(elim_source_t)); + if (cfg.src == NULL) { + cyaml_free(&cyaml_cfg, &elim_config_schema, ycfg, 0); + return -ENOMEM; + } + } + + rc = 0; + for (i = 0; i < cfg.nsrc; i++) { + yaml_elim_source_t* ys = &(ycfg->external_resources[i]); + + /* A source without name or command can never report -- fail + * loudly at load, not silently at runtime. */ + if ((ys->name == NULL) || (ys->command == NULL)) { + rc = -EINVAL; + break; + } + + cfg.src[i].name = strdup(ys->name); + cfg.src[i].command = strdup(ys->command); + cfg.src[i].interval = (int)ys->interval; + + if ((cfg.src[i].name == NULL) || (cfg.src[i].command == NULL)) { + rc = -ENOMEM; + break; + } + } + + cyaml_free(&cyaml_cfg, &elim_config_schema, ycfg, 0); + + if (rc < 0) { + elim_config_fini(&cfg); + return rc; + } + + *cfg_out = cfg; + + return 0; +} + +int elim_config_load(const char* path, elim_config_t* cfg_out) +{ + char* dir; + char* name; + char* slash; + int rc; + + if ((path == NULL) || (cfg_out == NULL)) { + return -EINVAL; + } + + dir = strdup(path); + if (dir == NULL) { + return -ENOMEM; + } + + slash = strrchr(dir, '/'); + if (slash == NULL) { + name = dir; + dir = strdup("."); + if (dir == NULL) { + free(name); + return -ENOMEM; + } + } else { + *slash = '\0'; + name = slash + 1; + if (*name == '\0') { + free(dir); + return -EINVAL; + } + if (dir == name - 1) { + /* path was "/name" -- dir is "" */ + free(dir); + dir = strdup("/"); + if (dir == NULL) { + return -ENOMEM; + } + } + } + + rc = elim_config_load_path(dir, name, cfg_out); + + free(dir); + + return rc; +} + +void elim_config_fini(elim_config_t* cfg) +{ + int i; + + if (cfg == NULL) { + return; + } + + for (i = 0; i < cfg->nsrc; i++) { + free(cfg->src[i].name); + free(cfg->src[i].command); + } + free(cfg->src); + + memset(cfg, 0, sizeof(*cfg)); +} + +/* ── output parse (elim contract) ─────────────────────────────────────── */ + +/* + * The elim contract: the first non-empty line carries " " or + * a bare number; leading/trailing whitespace ignored; anything else is + * garbage -> NAN (the caller owns the fail-open policy). Same shape as + * esd_parse_script_output -- a number is a number wherever it stands. + */ +double elim_parse_script_output(const char* out) +{ + const char* p; + char* endp = NULL; + double v; + + if (out == NULL) { + return NAN; + } + + p = out; + while (*p != '\0') { + while ((*p == ' ') || (*p == '\t') || (*p == '\r') || (*p == '\n')) { + p++; + } + if (*p == '\0') { + break; + } + + v = strtod(p, &endp); + if ((endp != NULL) && (endp != p)) { + return v; + } + + /* skip this non-numeric token and try the next one */ + while ((*p != '\0') && (*p != ' ') && (*p != '\t') && (*p != '\r') + && (*p != '\n')) { + p++; + } + } + + return NAN; +} + +/* ── script runner (posix_spawn + watchdog, esd_script.c shape) ───────── */ + +static int elim_script_spawn(const char* command, int out_fd, pid_t* pid_out) +{ + posix_spawn_file_actions_t fa; + posix_spawnattr_t attr; + sigset_t none, all; + char* argv[] = { (char*)"sh", (char*)"-c", (char*)command, NULL }; + extern char** environ; + pid_t pid; + int rc; + + sigemptyset(&none); + sigfillset(&all); + + rc = posix_spawn_file_actions_init(&fa); + if (rc == 0) { + rc = posix_spawn_file_actions_adddup2(&fa, out_fd, STDOUT_FILENO); + } + if (rc == 0) { + rc = posix_spawn_file_actions_addopen(&fa, STDERR_FILENO, "/dev/null", + O_WRONLY, 0); + } + if (rc == 0) { + rc = posix_spawnattr_init(&attr); + } + if (rc == 0) { + rc = posix_spawnattr_setflags( + &attr, POSIX_SPAWN_SETSIGMASK | POSIX_SPAWN_SETSIGDEF); + } + if (rc == 0) { + rc = posix_spawnattr_setsigmask(&attr, &none); + } + if (rc == 0) { + rc = posix_spawnattr_setsigdefault(&attr, &all); + } + if (rc == 0) { + rc = posix_spawn(&pid, "/bin/sh", &fa, &attr, argv, environ); + } + posix_spawnattr_destroy(&attr); + posix_spawn_file_actions_destroy(&fa); + + if (rc != 0) { + return -rc; + } + + *pid_out = pid; + + return 0; +} + +int elim_script_collect(const char* command, char* out, size_t outsz) +{ + int fds[2]; + pid_t pid = -1; + size_t total = 0; + int elapsed_ms = 0; + int rc; + int wstatus = 0; + struct pollfd pfd; + + if ((command == NULL) || (*command == '\0') || (out == NULL) + || (outsz == 0)) { + return -EINVAL; + } + + out[0] = '\0'; + + if (pipe(fds) != 0) { + rc = -errno; + XCECD_LOG(XCE_LOG_ERROR, "elim: pipe failed (%s)", strerror(-rc)); + return rc; + } + + rc = elim_script_spawn(command, fds[1], &pid); + close(fds[1]); /* parent's copy -- child holds the write end now */ + if (rc < 0) { + close(fds[0]); + XCECD_LOG(XCE_LOG_ERROR, "elim: spawn failed for \"%s\" (%s)", command, + strerror(-rc)); + return rc; + } + + /* Bound the read at the watchdog deadline: a script that hangs must + * not stall the sync thread -- the value it was going to report + * belongs to a dead world anyway. */ + pfd.fd = fds[0]; + pfd.events = POLLIN; + + while (total < outsz - 1) { + int pret = poll(&pfd, 1, 100); + + if (pret < 0) { + if (errno == EINTR) { + continue; + } + rc = -errno; + XCECD_LOG(XCE_LOG_ERROR, "elim: poll failed for \"%s\" (%s)", + command, strerror(-rc)); + kill(pid, SIGKILL); + waitpid(pid, &wstatus, 0); + close(fds[0]); + return rc; + } + if (pret == 0) { + elapsed_ms += 100; + if (elapsed_ms >= elim_script_timeout_ms) { + XCECD_LOG(XCE_LOG_WARN, + "elim: command \"%s\" exceeded %d ms -- killing " + "it, value not refreshed this round", + command, elim_script_timeout_ms); + kill(pid, SIGKILL); + waitpid(pid, &wstatus, 0); + close(fds[0]); + return -ETIMEDOUT; + } + continue; + } + + if (pfd.revents & (POLLERR | POLLHUP)) { + ssize_t n = read(fds[0], out + total, outsz - 1 - total); + if (n > 0) { + total += (size_t)n; + } + break; + } + if (pfd.revents & POLLIN) { + ssize_t n = read(fds[0], out + total, outsz - 1 - total); + if (n <= 0) { + break; /* EOF or error: done reading */ + } + total += (size_t)n; + } + } + + out[total] = '\0'; + close(fds[0]); + + /* reap regardless of which path brought us here */ + while (waitpid(pid, &wstatus, 0) < 0) { + if (errno != EINTR) { + break; + } + } + + if (total == 0) { + XCECD_LOG(XCE_LOG_WARN, + "elim: command \"%s\" produced no output", command); + return -ENODATA; + } + + return 0; +} + +/* ── getter plumbing: per-source cache behind the context-free API ────── */ + +/* resource_sync's getter signature is context-free (char* (*)(void)) -- the + * per-source state (command, cached value, next-refresh deadline) lives in + * this static array, indexed by registration order. MAX_LOAD_INDICES (64) + * bounds the registry; the config loader's own parse bounds us to a sane + * subset of that, and elim_register_sources' capacity failure surfaces at + * startup. */ +typedef struct { + char* command; + int interval; /* seconds; 0 = refresh every collect */ + double value; /* last good parse */ + bool have_value; + time_t next_run; /* epoch after which the script re-runs */ +} elim_source_state_t; + +static elim_source_state_t* g_elim_states = NULL; +static int g_elim_nstates = 0; +static pthread_mutex_t g_elim_mutex = PTHREAD_MUTEX_INITIALIZER; + +static char* elim_getter_run(int slot) +{ + elim_source_state_t* st = &g_elim_states[slot]; + char buf[4096]; + int rc; + double v; + char* out_str; + time_t now; + + /* interval cache: 0 = run the script on every collect; otherwise the + * script re-runs only once its interval has elapsed since the last + * good run, and the cached value is served in between (the send + * cadence is the sync thread's 15-tick cycle regardless -- the interval + * governs script-run frequency, not push frequency). */ + now = time(NULL); + if (st->have_value && (st->interval > 0) && (now < st->next_run)) { + out_str = (char*)malloc(64); + if (out_str == NULL) { + return NULL; + } + snprintf(out_str, 64, "%f", st->value); + return out_str; + } + + rc = elim_script_collect(st->command, buf, sizeof(buf)); + if (rc < 0) { + /* Fail-open: keep the last known value (the collector died but the + * host is still there; the sync thread keeps reporting). */ + if (!st->have_value) { + return strdup("0"); + } + return strdup(""); + } + + v = elim_parse_script_output(buf); + if (isnan(v)) { + XCECD_LOG(XCE_LOG_WARN, + "elim: command \"%s\" output did not parse as a value " + "(got %.60s)", st->command, buf); + if (!st->have_value) { + return strdup("0"); + } + return strdup(""); + } + + st->value = v; + st->have_value = true; + st->next_run = time(NULL) + st->interval; + + /* collect_load_indices converts per valtype (fvalue: atof+free) and + * owns the string from here. */ + out_str = (char*)malloc(64); + if (out_str == NULL) { + return NULL; + } + snprintf(out_str, 64, "%f", v); + + return out_str; +} + +/* One trampoline per possible slot; the getter table indexes the static + * state array. (The registry API carries no user pointer.) */ +static char* elim_getter_0(void) { return elim_getter_run(0); } +static char* elim_getter_1(void) { return elim_getter_run(1); } +static char* elim_getter_2(void) { return elim_getter_run(2); } +static char* elim_getter_3(void) { return elim_getter_run(3); } +static char* elim_getter_4(void) { return elim_getter_run(4); } +static char* elim_getter_5(void) { return elim_getter_run(5); } +static char* elim_getter_6(void) { return elim_getter_run(6); } +static char* elim_getter_7(void) { return elim_getter_run(7); } +static char* elim_getter_8(void) { return elim_getter_run(8); } +static char* elim_getter_9(void) { return elim_getter_run(9); } +static char* elim_getter_10(void) { return elim_getter_run(10); } +static char* elim_getter_11(void) { return elim_getter_run(11); } +static char* elim_getter_12(void) { return elim_getter_run(12); } +static char* elim_getter_13(void) { return elim_getter_run(13); } +static char* elim_getter_14(void) { return elim_getter_run(14); } +static char* elim_getter_15(void) { return elim_getter_run(15); } + +static char* (*const g_elim_getters[])(void) = { + elim_getter_0, elim_getter_1, elim_getter_2, elim_getter_3, + elim_getter_4, elim_getter_5, elim_getter_6, elim_getter_7, + elim_getter_8, elim_getter_9, elim_getter_10, elim_getter_11, + elim_getter_12, elim_getter_13, elim_getter_14, elim_getter_15, +}; + +#define ELIM_MAX_SOURCES ((int)(sizeof(g_elim_getters) / sizeof(g_elim_getters[0]))) + +/* There is exactly one getter call site -- collect_load_indices() on the + * sync thread -- but registration happens on main before that thread + * exists; a mutex keeps the state array vs. getter table relationship + * honest without serializing the runs themselves. */ +int elim_register_sources(const elim_config_t* cfg) +{ + int i; + int rc = 0; + + if ((cfg == NULL) || (cfg->nsrc < 0) + || ((cfg->nsrc > 0) && (cfg->src == NULL))) { + return -EINVAL; + } + + if (cfg->nsrc == 0) { + return 0; + } + + if (cfg->nsrc > ELIM_MAX_SOURCES) { + XCECD_LOG(XCE_LOG_ERROR, + "elim: %d external resources configured, at most %d " + "supported", cfg->nsrc, ELIM_MAX_SOURCES); + return -EINVAL; + } + + pthread_mutex_lock(&g_elim_mutex); + + /* The 11 builtins register lazily at collect time; an elim source named + * "mem" registered before them would win the name race and SHADOW a + * builtin (the builtin's own registration then bounces as a dup). + * Force the builtin init first, so a name collision is a clean -EEXIST + * here, at startup, where the operator sees it. */ + resource_sync_ensure_builtin_load_indices(); + + g_elim_states = (elim_source_state_t*)calloc( + (size_t)cfg->nsrc, sizeof(elim_source_state_t)); + if (g_elim_states == NULL) { + pthread_mutex_unlock(&g_elim_mutex); + return -ENOMEM; + } + g_elim_nstates = cfg->nsrc; + + for (i = 0; i < cfg->nsrc; i++) { + if (!resource_sync_register_load_index( + cfg->src[i].name, Value_u_fvalue, g_elim_getters[i], false)) { + /* Duplicate name (another source, or one of the 11 builtins -- + * a user script must not shadow "mem"/"r15s"/...): surface the + * misconfiguration at startup, not as a silent missing value. */ + XCECD_LOG(XCE_LOG_ERROR, + "elim: cannot register external resource '%s' -- " + "name already in use", + cfg->src[i].name); + rc = -EEXIST; + break; + } + + g_elim_states[i].command = strdup(cfg->src[i].command); + if (g_elim_states[i].command == NULL) { + resource_sync_unregister_load_index(cfg->src[i].name); + rc = -ENOMEM; + break; + } + g_elim_states[i].interval = cfg->src[i].interval; + g_elim_states[i].have_value = false; + g_elim_states[i].next_run = 0; + + XCECD_LOG(XCE_LOG_INFO, + "elim: registered external resource '%s' (command: %s, " + "interval: %ds)", + cfg->src[i].name, g_elim_states[i].command, + g_elim_states[i].interval); + } + + pthread_mutex_unlock(&g_elim_mutex); + + if (rc < 0) { + elim_unregister_sources(cfg); + } + + return rc; +} + +void elim_unregister_sources(const elim_config_t* cfg) +{ + int i; + + if ((cfg == NULL) || (cfg->src == NULL)) { + return; + } + + pthread_mutex_lock(&g_elim_mutex); + + for (i = 0; i < cfg->nsrc; i++) { + resource_sync_unregister_load_index(cfg->src[i].name); + } + + for (i = 0; i < g_elim_nstates; i++) { + free(g_elim_states[i].command); + } + free(g_elim_states); + g_elim_states = NULL; + g_elim_nstates = 0; + + pthread_mutex_unlock(&g_elim_mutex); +} diff --git a/src/worker/src/main.c b/src/worker/src/main.c index bd27e9d6..e00f5656 100644 --- a/src/worker/src/main.c +++ b/src/worker/src/main.c @@ -394,10 +394,12 @@ int main(int argc, char* argv[]) char logfile[1024] = { 0 }; char* logdir = NULL; + const char* config_file = NULL; static struct option long_opts[] = { { "peers", required_argument, NULL, OPT_PEERS }, { "version", no_argument, NULL, OPT_VERSION }, + { "config", required_argument, NULL, 'c' }, { "help", no_argument, NULL, 'h' }, { NULL, 0, NULL, 0 } }; @@ -418,12 +420,15 @@ int main(int argc, char* argv[]) STR(VERSION_BUILD_TIME), STR(VERSION_DIRTY_FINGERPRINT)); - while ((opt = getopt_long(argc, argv, "n:l:vh", long_opts, NULL)) + while ((opt = getopt_long(argc, argv, "n:l:c:vh", long_opts, NULL)) != -1) { switch (opt) { case 'n': config.virutal_name = optarg; break; + case 'c': + config_file = optarg; + break; case 'l': if (optarg) { if (logdir) { @@ -450,9 +455,13 @@ int main(int argc, char* argv[]) return 0; case 'h': default: - printf("Usage: %s [-n name] [-l log_dir] [-v] " + printf("Usage: %s [-n name] [-l log_dir] [-c config] [-v] " "[--peers ctrl-1=host:port,...] " "[-h help]\n" + " -c FILE elim collector config (external " + "resources reported by\n" + " scripts on this host; design " + "external-license-resources §3.3b)\n" " --peers LIST HA controller candidate list: " "[name=]host:port,...\n" " When omitted, the XCE_CONTROLLER_PEERS " @@ -638,6 +647,41 @@ int main(int argc, char* argv[]) } #endif + /* elim collectors (design §3.3b): load the external_resources config + * before xcecd_init() registers its sources. No -c (or a file without + * the section) means zero external resources -- plain worker behavior. + * A config that is present but BROKEN stops startup loudly: a worker + * whose operator asked for elim reporting must not silently run + * without it (same fail-loud-at-load rule as esd's config). */ + if (config_file != NULL) { + elim_config_t elim_cfg; + int erc = elim_config_load(config_file, &elim_cfg); + + if (erc == -ENOENT) { + XCECD_LOG(XCE_LOG_ERROR, + "elim config file not found: %s", config_file); + fprintf(stderr, "xcecd: elim config file not found: %s\n", + config_file); + FREEUP(logdir); + return 1; + } + if (erc < 0) { + XCECD_LOG(XCE_LOG_ERROR, + "invalid elim config file %s (rc=%d) -- a source " + "needs both name and command", config_file, erc); + fprintf(stderr, + "xcecd: invalid elim config file %s (rc=%d)\n", + config_file, erc); + FREEUP(logdir); + return 1; + } + config.elim.nsrc = elim_cfg.nsrc; + config.elim.src = elim_cfg.src; /* ownership -> xcecd_config_t */ + XCECD_LOG(XCE_LOG_INFO, + "elim: %d external resource(s) from %s", + config.elim.nsrc, config_file); + } + if (xcecd_config_init(&config) != 0) { XCECD_LOG(XCE_LOG_ERROR, "Failed to initialize daemon configuration."); return 1; diff --git a/src/worker/src/resource_sync.c b/src/worker/src/resource_sync.c index 18666005..3e08424d 100644 --- a/src/worker/src/resource_sync.c +++ b/src/worker/src/resource_sync.c @@ -77,8 +77,17 @@ static void init_builtin_load_indices(void) * on resource_sync_thread()'s single dedicated pthread in production, * or single-threaded test code). */ g_load_indices_capacity = MAX_LOAD_INDICES; - g_load_indices = (load_index_info_t*)calloc(g_load_indices_capacity, - sizeof(load_index_info_t)); + if (g_load_indices == NULL) { + g_load_indices = (load_index_info_t*)calloc(g_load_indices_capacity, + sizeof(load_index_info_t)); + } + /* Never realloc over a populated array: since resource_sync_ensure_ + * builtin_load_indices() this function also runs with the array already + * alive (external entries registered first -- elim), and replacing it + * leaked the old one while leaving g_load_indices_count pointing past + * the fresh calloc (strcmp on garbage slots, segfault). The dup check + * in resource_sync_register_load_index() makes re-running the + * registration list idempotent. */ // register built-in load indices resource_sync_register_load_index("r15s", Value_u_fvalue, node_info_get_15s, @@ -105,6 +114,28 @@ static void init_builtin_load_indices(void) true); } +/** + * @brief Ensures the registry exists AND the 11 builtin load indices are + * registered in it, whichever external module (elim) queries or + * registers first. Idempotent; single-threaded callers only in + * practice (startup registration / the sync thread's collect), but + * safe under the registry mutex discipline its own callers keep. + */ +void resource_sync_ensure_builtin_load_indices(void) +{ + if (g_load_indices == NULL) { + init_builtin_load_indices(); + return; + } + /* init_builtin_load_indices() ran but the array was later torn down + * (resource_sync_destroy's cleanup) and a register-before-init caller + * re-populated it with external entries only -- the builtins must go + * in before the first report. */ + if (g_load_indices_count < BUILTIN_LOAD_INDICES_COUNT) { + init_builtin_load_indices(); + } +} + /** * @brief Registers a new load index. * @param name The name of the load index. @@ -123,6 +154,27 @@ bool resource_sync_register_load_index(const char* name, int valtype, pthread_mutex_lock(&g_load_indices_mutex); + /* elim (design §3.3b): external registration happens at daemon startup + * -- BEFORE the sync thread's first collect_load_indices() lazily + * allocated the array. A capacity-0 registry here used to reject every + * such registration with "Maximum load indices capacity reached (0)", + * silently dropping every configured external resource. Allocate the + * registry on first use, same as the lazy builtin init does; init_ + * builtin_load_indices() itself stays where it is (collect-time) so the + * 11 builtins keep registering exactly once per populated-registry + * window (bug-1756's contract -- its only caller's NULL check). */ + if (g_load_indices == NULL) { + g_load_indices_capacity = MAX_LOAD_INDICES; + g_load_indices = (load_index_info_t*)calloc( + g_load_indices_capacity, sizeof(load_index_info_t)); + if (g_load_indices == NULL) { + XCECD_LOG(XCE_LOG_ERROR, "Failed to allocate load indices array"); + g_load_indices_capacity = 0; + pthread_mutex_unlock(&g_load_indices_mutex); + return false; + } + } + for (int i = 0; i < g_load_indices_count; i++) { if (strcmp(g_load_indices[i].name, name) == 0) { XCECD_LOG(XCE_LOG_WARN, "Load index '%s' already exists", name); @@ -194,9 +246,7 @@ bool resource_sync_unregister_load_index(const char* name) static xce_resval_t* collect_load_indices(int* count) { - if (g_load_indices == NULL) { - init_builtin_load_indices(); - } + resource_sync_ensure_builtin_load_indices(); pthread_mutex_lock(&g_load_indices_mutex); diff --git a/src/worker/src/xcecd.c b/src/worker/src/xcecd.c index 466656eb..5a8cdb58 100644 --- a/src/worker/src/xcecd.c +++ b/src/worker/src/xcecd.c @@ -395,6 +395,22 @@ int xcecd_init(const xcecd_config_t* config) * xcecd_start() already ran. */ xcecd_ctx.cluster_cfg = config->cluster_cfg; + /* elim collectors (external-license-resources design §3.3b): register + * the configured external resources as custom load indices BEFORE the + * resource_sync thread starts (xcecd_start() -> resource_sync_start()), + * so the first 15-tick report already carries them. Ownership of + * config->elim moves here (config is read-only). */ + xcecd_ctx.elim.nsrc = config->elim.nsrc; + xcecd_ctx.elim.src = config->elim.src; + if (xcecd_ctx.elim.nsrc > 0) { + if (elim_register_sources(&xcecd_ctx.elim) < 0) { + XCECD_LOG(XCE_LOG_ERROR, + "Failed to register elim external resources -- " + "check external_resources config"); + goto cleanup_remote_host; + } + } + /* thread pool initializing */ xcecd_ctx.thread_pool = thread_pool_create(config->thread_pool_size); if (!xcecd_ctx.thread_pool) { @@ -457,6 +473,7 @@ cleanup_process_manager: cleanup_thread_pool: thread_pool_destroy(xcecd_ctx.thread_pool); cleanup_remote_host: + elim_unregister_sources(&xcecd_ctx.elim); free(xcecd_ctx.remote_host); free(xcecd_ctx.work_dir); free(xcecd_ctx.db_path); @@ -575,6 +592,9 @@ void xcecd_cleanup(void) resource_sync_stop(xcecd_ctx.resource_sync); resource_sync_destroy(xcecd_ctx.resource_sync); } + /* elim teardown AFTER resource_sync_destroy (which tears the registry + * down anyway -- this frees the source state and command strings). */ + elim_unregister_sources(&xcecd_ctx.elim); if (xcecd_ctx.db) { xcecd_db_close(xcecd_ctx.db); } diff --git a/src/worker/testsuite/meson.build b/src/worker/testsuite/meson.build index 2464387f..bca9b9f8 100644 --- a/src/worker/testsuite/meson.build +++ b/src/worker/testsuite/meson.build @@ -45,6 +45,23 @@ test('worker resource_sync test', resource_sync_test, timeout: 60, env: ['ASAN_OPTIONS=detect_leaks=0']) +# external-license-resources design §3.3b: the elim collector -- a +# config-driven script runner (posix_spawn + watchdog) that registers +# user-defined per-host load indices into resource_sync's registry at +# worker startup. White-box #includes elim.c AND resource_sync.c (the +# registry capacity-0-before-init trap is only observable against the real +# registry); see test_elim.c's header comment. +elim_test = executable('elim_test', + 'test_elim.c', + include_directories: [worker_test_incdir], + dependencies: worker_test_deps, + install: false) + +test('worker elim test', elim_test, + suite: 'worker', + timeout: 60, + env: ['ASAN_OPTIONS=detect_leaks=0']) + # bug-1554: real maxswap (sysinfo() totalswap)/maxtmp (statvfs("/tmp")) # detection -- white-box #include of hardware_monitor.c, same convention as # resource_sync_test above. diff --git a/src/worker/testsuite/test_elim.c b/src/worker/testsuite/test_elim.c new file mode 100644 index 00000000..ebfa1a93 --- /dev/null +++ b/src/worker/testsuite/test_elim.c @@ -0,0 +1,566 @@ +/* + * test_elim.c -- elim collector module (external-license-resources design + * §3.3b): xcecd gains a config-driven script collector that registers + * user-defined load indices into resource_sync's registry (the previously + * dead external-register API), so per-host "external resources" reported by + * user scripts on each host flow the live load-indices push chain + * (collect_load_indices -> xcecd_send_load_indices -> controller + * update_val_res -> scheduler RESVALS_DELTA -> sched_check_host_satisfy_). + * + * White-box #include of ../src/elim.c (same convention as + * test_resource_sync.c) plus ../src/esd_script-shaped spawn code lives in + * elim.c itself; the config parse is exercised against real temp files + * (esd_test's mkdtemp pattern). The spawn/watchdog half IS unit tested here + * (unlike esd's test, which deferred it to E2E): a real /bin/sh child with + * a real timeout kill is deterministic and cheap, and the watchdog is the + * one piece of elim.c that can hang the sync thread if it regresses. + * + * resource_sync.c is ALSO #included (after elim.c) to exercise the real + * registry rather than a stub: the capacity-0-before-init trap this phase + * fixes (elim registers at startup, before the sync thread's first collect + * lazily allocated the array) is only observable against the real code. + */ +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include "resource_sync.h" +#include "xcecd.h" + +/* Real globals the white-box resource_sync.c references (test_resource_sync.c + * convention). */ +xcecd_context_t xcecd_ctx; + +/* Stubs for the 11 builtin load-index getters (node_information.c, not + * linked here). */ +static char* stub_getter(void) +{ + return strdup("1.0"); +} + +char* node_info_get_15s(void) { return stub_getter(); } +char* node_info_get_1m(void) { return stub_getter(); } +char* node_info_get_15m(void) { return stub_getter(); } +char* node_info_get_ut(void) { return stub_getter(); } +char* node_info_get_pg(void) { return stub_getter(); } +char* node_info_get_ls(void) { return stub_getter(); } +char* node_info_get_it(void) { return stub_getter(); } +char* node_info_get_swap(void) { return stub_getter(); } +char* node_info_get_mem(void) { return stub_getter(); } +char* node_info_get_tmp(void) { return stub_getter(); } +char* node_info_get_io(void) { return stub_getter(); } + +/* Spy on the send (controller_connector.c, not linked here). */ +static int g_send_load_indices_call_count = 0; + +bool xcecd_send_load_indices(xce_resval_t** vals, int count) +{ + (void)vals; + (void)count; + g_send_load_indices_call_count++; + return true; +} + +/* resource_sync.c's resync gate (xcecd_resync.c, not linked here). */ +static bool g_test_resync_pending = false; + +bool xcecd_resync_is_pending(void) { return g_test_resync_pending; } + +#include "../src/elim.c" +#include "../src/resource_sync.c" + +static char g_tmpdir[256]; + +void setUp(void) +{ + snprintf(g_tmpdir, sizeof(g_tmpdir), "/tmp/xce_elim_testXXXXXX"); + TEST_ASSERT_NOT_NULL(mkdtemp(g_tmpdir)); + + memset(&xcecd_ctx, 0, sizeof(xcecd_ctx)); + xcecd_ctx.virtual_name = (char*)"t1"; + g_test_resync_pending = false; + g_send_load_indices_call_count = 0; +} + +void tearDown(void) +{ + /* Drop any registrations this test left so the next test starts from a + * clean registry: resource_sync_destroy on a throwaway object runs + * resource_sync_cleanup_load_indices() (static in the #included + * resource_sync.c, reachable only through this public entry). */ + resource_sync_destroy(resource_sync_create()); + + if (g_tmpdir[0] != '\0') { + char path[300]; + + snprintf(path, sizeof(path), "rm -rf %s", g_tmpdir); + if (system(path) != 0) { + /* best effort */ + } + g_tmpdir[0] = '\0'; + } +} + +static void write_file(const char* dir, const char* name, const char* content) +{ + char path[512]; + FILE* f; + + snprintf(path, sizeof(path), "%s/%s", dir, name); + f = fopen(path, "w"); + TEST_ASSERT_NOT_NULL(f); + fputs(content, f); + fclose(f); +} + +/* ── elim config parse ────────────────────────────────────────────────── */ + +static void test_elim_config_load_full_file(void) +{ + elim_config_t cfg; + int rc; + + write_file(g_tmpdir, "elim.yaml", + "external_resources:\n" + " - name: dynres1\n" + " command: /opt/xce/scripts/report-dynres1.sh\n" + " interval: 30\n" + " - name: gpu_free\n" + " command: /usr/local/bin/gpu-free.sh\n" + " interval: 60\n"); + + rc = elim_config_load_path(g_tmpdir, "elim.yaml", &cfg); + + TEST_ASSERT_EQUAL_INT(0, rc); + TEST_ASSERT_EQUAL_INT(2, cfg.nsrc); + TEST_ASSERT_EQUAL_STRING("dynres1", cfg.src[0].name); + TEST_ASSERT_EQUAL_STRING("/opt/xce/scripts/report-dynres1.sh", + cfg.src[0].command); + TEST_ASSERT_EQUAL_INT(30, cfg.src[0].interval); + TEST_ASSERT_EQUAL_STRING("gpu_free", cfg.src[1].name); + TEST_ASSERT_EQUAL_INT(60, cfg.src[1].interval); + + elim_config_fini(&cfg); +} + +static void test_elim_config_interval_defaults_when_missing(void) +{ + elim_config_t cfg; + int rc; + + write_file(g_tmpdir, "elim.yaml", + "external_resources:\n" + " - name: dynres1\n" + " command: /opt/xce/scripts/r.sh\n"); + + rc = elim_config_load_path(g_tmpdir, "elim.yaml", &cfg); + + TEST_ASSERT_EQUAL_INT(0, rc); + TEST_ASSERT_EQUAL_INT(1, cfg.nsrc); + /* 0 = "every send tick" -- the getter cache refreshes each time the + * sync thread collects. */ + TEST_ASSERT_EQUAL_INT(0, cfg.src[0].interval); + + elim_config_fini(&cfg); +} + +static void test_elim_config_missing_file_is_enoent(void) +{ + elim_config_t cfg; + int rc; + + rc = elim_config_load_path(g_tmpdir, "no-such-file.yaml", &cfg); + + TEST_ASSERT_EQUAL_INT(-ENOENT, rc); +} + +static void test_elim_config_missing_command_is_einval(void) +{ + elim_config_t cfg; + int rc; + + /* A source without a command can never report -- fail loudly at load, + * not silently at runtime (esd_config.c's validation rule). */ + write_file(g_tmpdir, "elim.yaml", + "external_resources:\n" + " - name: dynres1\n" + " interval: 30\n"); + + rc = elim_config_load_path(g_tmpdir, "elim.yaml", &cfg); + + TEST_ASSERT_EQUAL_INT(-EINVAL, rc); +} + +static void test_elim_config_missing_name_is_einval(void) +{ + elim_config_t cfg; + int rc; + + write_file(g_tmpdir, "elim.yaml", + "external_resources:\n" + " - command: /opt/xce/scripts/r.sh\n"); + + rc = elim_config_load_path(g_tmpdir, "elim.yaml", &cfg); + + TEST_ASSERT_EQUAL_INT(-EINVAL, rc); +} + +static void test_elim_config_empty_or_absent_section_is_ok(void) +{ + elim_config_t cfg; + int rc; + + /* A worker config with no external_resources key is the normal case for + * every host without elim scripts -- must load to zero sources, not + * error. */ + write_file(g_tmpdir, "elim.yaml", "external_resources: []\n"); + + rc = elim_config_load_path(g_tmpdir, "elim.yaml", &cfg); + + TEST_ASSERT_EQUAL_INT(0, rc); + TEST_ASSERT_EQUAL_INT(0, cfg.nsrc); + TEST_ASSERT_NULL(cfg.src); + + elim_config_fini(&cfg); +} + +/* ── elim output parse ────────────────────────────────────────────────── */ + +static void test_elim_parse_output_name_value_line(void) +{ + double v; + + /* The elim contract: first non-empty line carries " ". */ + v = elim_parse_script_output("dynres1 42\n"); + TEST_ASSERT_FLOAT_WITHIN(1e-6, 42.0, v); + + v = elim_parse_script_output("\n\n# comment-ish noise 7 8\ngpu_free 5.5\n"); + TEST_ASSERT_FLOAT_WITHIN(1e-6, 7.0, v); +} + +static void test_elim_parse_output_bare_value_line(void) +{ + double v; + + v = elim_parse_script_output("12\n"); + TEST_ASSERT_FLOAT_WITHIN(1e-6, 12.0, v); + + v = elim_parse_script_output(" 3.5 \n"); + TEST_ASSERT_FLOAT_WITHIN(1e-6, 3.5, v); +} + +static void test_elim_parse_output_garbage_is_nan(void) +{ + TEST_ASSERT(isnan(elim_parse_script_output("no numbers here\n"))); + TEST_ASSERT(isnan(elim_parse_script_output(""))); + TEST_ASSERT(isnan(elim_parse_script_output(NULL))); +} + +/* ── registry wiring: register at startup, before any collect ─────────── */ + +/* THE trap this phase fixes: before it, resource_sync_register_load_index + * called at daemon startup (when g_load_indices is still NULL -- the array + * is only allocated by collect_load_indices' lazy init_builtin_load_indices, + * i.e. on the sync thread's first 15th tick) failed with "capacity reached + * (0)" and every elim source silently vanished. Registration must + * self-initialize. */ +static void test_register_load_index_before_lazy_init_succeeds(void) +{ + bool ok; + + /* No collect_load_indices() call ever happened in this process yet. */ + TEST_ASSERT_NULL_MESSAGE(g_load_indices, + "precondition: registry not yet initialized"); + + ok = resource_sync_register_load_index("dynres1", Value_u_fvalue, + stub_getter, false); + TEST_ASSERT_TRUE_MESSAGE( + ok, "registering before the lazy builtin init must not fail with " + "'capacity reached (0)'"); + + /* And the value must actually flow: collect_load_indices now includes + * the elim index among the builtins. */ + xce_resval_t* vals; + int count = 0; + int found = 0; + + vals = collect_load_indices(&count); + TEST_ASSERT_NOT_NULL(vals); + for (int i = 0; i < count; i++) { + if (strcmp(vals[i].name, "dynres1") == 0) { + found++; + TEST_ASSERT_EQUAL_STRING("t1", vals[i].host); + TEST_ASSERT_EQUAL_INT(Value_u_fvalue, vals[i].val->which); + TEST_ASSERT_FLOAT_WITHIN(1e-6, 1.0, vals[i].val->u.fvalue); + } + /* The host tag is the join identity for every report (bug-1569). */ + TEST_ASSERT_EQUAL_STRING("t1", vals[i].host); + } + TEST_ASSERT_EQUAL_INT(1, found); + + free_resvals(vals, count); +} + +static void test_elim_sources_register_and_flow_through_registry(void) +{ + elim_config_t cfg; + int rc; + + write_file(g_tmpdir, "elim.yaml", + "external_resources:\n" + " - name: dynres1\n" + " command: echo dynres1 7\n" + " interval: 0\n"); + + rc = elim_config_load_path(g_tmpdir, "elim.yaml", &cfg); + TEST_ASSERT_EQUAL_INT(0, rc); + + rc = elim_register_sources(&cfg); + TEST_ASSERT_EQUAL_INT(0, rc); + + /* The getter runs the script: first collect refreshes the cache. */ + xce_resval_t* vals; + int count = 0; + int found = 0; + + vals = collect_load_indices(&count); + TEST_ASSERT_NOT_NULL(vals); + for (int i = 0; i < count; i++) { + if (strcmp(vals[i].name, "dynres1") == 0) { + found++; + TEST_ASSERT_EQUAL_INT(Value_u_fvalue, vals[i].val->which); + /* "echo dynres1 7" -> name-value line -> 7.0 */ + TEST_ASSERT_FLOAT_WITHIN(1e-6, 7.0, vals[i].val->u.fvalue); + } + } + TEST_ASSERT_EQUAL_INT_MESSAGE( + 1, found, "the elim-registered index must reach collect_load_indices"); + + free_resvals(vals, count); +} + +static void test_elim_register_rejects_duplicate_name(void) +{ + elim_config_t cfg; + int rc; + + write_file(g_tmpdir, "elim.yaml", + "external_resources:\n" + " - name: dynres1\n" + " command: echo dynres1 1\n" + " - name: dynres1\n" + " command: echo dynres1 2\n"); + + rc = elim_config_load_path(g_tmpdir, "elim.yaml", &cfg); + TEST_ASSERT_EQUAL_INT(0, rc); + + /* The registry rejects the dup; elim_register_sources reports the + * failure so the operator sees the misconfiguration at startup. */ + TEST_ASSERT_EQUAL_INT(-EEXIST, elim_register_sources(&cfg)); +} + +static void test_elim_register_rejects_builtin_name_override(void) +{ + elim_config_t cfg; + int rc; + + /* "mem" is one of the 11 builtins -- a user script must not shadow it + * (the registry rejects the dup; the error surfaces at startup). */ + write_file(g_tmpdir, "elim.yaml", + "external_resources:\n" + " - name: mem\n" + " command: echo mem 999\n"); + + rc = elim_config_load_path(g_tmpdir, "elim.yaml", &cfg); + TEST_ASSERT_EQUAL_INT(0, rc); + + TEST_ASSERT_EQUAL_INT(-EEXIST, elim_register_sources(&cfg)); +} + +static void test_elim_unregister_sources_cleans_up(void) +{ + elim_config_t cfg; + int rc; + + write_file(g_tmpdir, "elim.yaml", + "external_resources:\n" + " - name: dynres1\n" + " command: echo dynres1 1\n"); + + rc = elim_config_load_path(g_tmpdir, "elim.yaml", &cfg); + TEST_ASSERT_EQUAL_INT(0, rc); + + TEST_ASSERT_EQUAL_INT(0, elim_register_sources(&cfg)); + TEST_ASSERT_TRUE(resource_sync_unregister_load_index("dynres1")); + TEST_ASSERT_FALSE(resource_sync_unregister_load_index("dynres1")); +} + +/* ── the script runner: real child, real watchdog ─────────────────────── */ + +static void test_elim_script_collect_runs_command(void) +{ + char out[128]; + int rc; + const char* script; + + script = "/bin/sh -c 'echo dynres1 42'"; + rc = elim_script_collect(script, out, sizeof(out)); + TEST_ASSERT_EQUAL_INT(0, rc); + TEST_ASSERT_FLOAT_WITHIN(1e-6, 42.0, elim_parse_script_output(out)); +} + +static void test_elim_script_collect_timeout_kills_child(void) +{ + char out[128]; + int rc; + int saved_timeout; + const char* script; + + /* A script that hangs must be killed at the watchdog deadline, not + * stall the sync thread forever (the esd_script watchdog contract). */ + script = "/bin/sh -c 'sleep 60'"; + saved_timeout = elim_script_timeout_ms; + elim_script_timeout_ms = 500; + + rc = elim_script_collect(script, out, sizeof(out)); + + elim_script_timeout_ms = saved_timeout; + + TEST_ASSERT_EQUAL_INT_MESSAGE(-ETIMEDOUT, rc, + "a hung script must hit the watchdog"); + + /* And the child must actually be reaped -- no zombie left behind. */ + TEST_ASSERT_EQUAL_INT_MESSAGE(-1, waitpid(-1, NULL, WNOHANG), + "no unreaped child may survive the kill"); +} + +static void test_elim_script_collect_empty_output_is_enodata(void) +{ + char out[128]; + int rc; + + rc = elim_script_collect("/bin/sh -c 'exit 0'", out, sizeof(out)); + TEST_ASSERT_EQUAL_INT(-ENODATA, rc); +} + +/* ── getter cache semantics: interval ─────────────────────────────────── */ + +static void test_elim_getter_refreshes_per_interval(void) +{ + elim_config_t cfg; + xce_resval_t* vals; + int count = 0; + static char counter_script[512]; + static char yaml[1024]; + int v1 = -1, v2 = -1; + resource_sync_t* sync; + + /* A script whose output changes on every run: the getter must return + * the FRESH value after the interval elapses (refresh), and the cached + * one before it (no re-run). */ + snprintf(counter_script, sizeof(counter_script), "%s/counter.sh", g_tmpdir); + { + FILE* f = fopen(counter_script, "w"); + TEST_ASSERT_NOT_NULL(f); + fprintf(f, + "#!/bin/sh\n" + "if [ -f %s.n ]; then n=$(cat %s.n); n=$((n+1)); else n=1; fi\n" + "echo $n > %s.n\n" + "echo \"dynres1 $n\"\n", + counter_script, counter_script, counter_script); + fclose(f); + chmod(counter_script, 0755); + } + + snprintf(yaml, sizeof(yaml), + "external_resources:\n" + " - name: dynres1\n" + " command: %s\n" + " interval: 3\n", + counter_script); + write_file(g_tmpdir, "elim.yaml", yaml); + + TEST_ASSERT_EQUAL_INT(0, elim_config_load_path(g_tmpdir, "elim.yaml", &cfg)); + TEST_ASSERT_EQUAL_INT(0, elim_register_sources(&cfg)); + + /* First collect: getter runs the script (counter=1). */ + vals = collect_load_indices(&count); + TEST_ASSERT_NOT_NULL(vals); + for (int i = 0; i < count; i++) { + if (strcmp(vals[i].name, "dynres1") == 0) { + v1 = (int)vals[i].val->u.fvalue; + } + } + free_resvals(vals, count); + TEST_ASSERT_EQUAL_INT(1, v1); + + /* Collect again immediately (interval=3 not yet elapsed): the cached + * value is returned, the counter script does NOT advance. */ + vals = collect_load_indices(&count); + TEST_ASSERT_NOT_NULL(vals); + for (int i = 0; i < count; i++) { + if (strcmp(vals[i].name, "dynres1") == 0) { + v2 = (int)vals[i].val->u.fvalue; + } + } + free_resvals(vals, count); + TEST_ASSERT_EQUAL_INT_MESSAGE( + 1, v2, "getter must serve the cached value within its interval"); + + /* After the interval elapses the getter re-runs the script. Drive it + * through a real resource_sync tick loop rather than sleeping 3s in + * wall-clock: seed the counter and run two 1s ticks. */ + sync = resource_sync_create(); + TEST_ASSERT_NOT_NULL(sync); + sync->load_report_counter = LOAD_INDICES_INTERVAL_DEFAULT - 1; + TEST_ASSERT_TRUE(resource_sync_start(sync)); + /* Two ticks cover the 1s sleep-per-tick of the sync thread; interval 3 + * seconds measured from the first run has now elapsed. */ + sleep(4); + + vals = collect_load_indices(&count); + TEST_ASSERT_NOT_NULL(vals); + for (int i = 0; i < count; i++) { + if (strcmp(vals[i].name, "dynres1") == 0) { + v2 = (int)vals[i].val->u.fvalue; + } + } + free_resvals(vals, count); + + resource_sync_stop(sync); + resource_sync_destroy(sync); + + TEST_ASSERT_EQUAL_INT_MESSAGE( + 2, v2, "getter must refresh the value once its interval elapses"); +} + +int main(void) +{ + UNITY_BEGIN(); + RUN_TEST(test_elim_config_load_full_file); + RUN_TEST(test_elim_config_interval_defaults_when_missing); + RUN_TEST(test_elim_config_missing_file_is_enoent); + RUN_TEST(test_elim_config_missing_command_is_einval); + RUN_TEST(test_elim_config_missing_name_is_einval); + RUN_TEST(test_elim_config_empty_or_absent_section_is_ok); + RUN_TEST(test_elim_parse_output_name_value_line); + RUN_TEST(test_elim_parse_output_bare_value_line); + RUN_TEST(test_elim_parse_output_garbage_is_nan); + RUN_TEST(test_register_load_index_before_lazy_init_succeeds); + RUN_TEST(test_elim_sources_register_and_flow_through_registry); + RUN_TEST(test_elim_register_rejects_duplicate_name); + RUN_TEST(test_elim_register_rejects_builtin_name_override); + RUN_TEST(test_elim_unregister_sources_cleans_up); + RUN_TEST(test_elim_script_collect_runs_command); + RUN_TEST(test_elim_script_collect_timeout_kills_child); + RUN_TEST(test_elim_script_collect_empty_output_is_enodata); + RUN_TEST(test_elim_getter_refreshes_per_interval); + return UNITY_END(); +} diff --git a/src/worker/testsuite/test_xcecd_startup_order.c b/src/worker/testsuite/test_xcecd_startup_order.c index ae2e4a62..06eab968 100644 --- a/src/worker/testsuite/test_xcecd_startup_order.c +++ b/src/worker/testsuite/test_xcecd_startup_order.c @@ -161,6 +161,17 @@ bool resource_sync_start(resource_sync_t* sync) } void resource_sync_stop(resource_sync_t* sync) { (void)sync; } +/* elim surface (elim.c, not linked here -- this test stubs every subsystem + * constructor the same way): xcecd_init() registers the configured sources + * (zero sources in this test's configs, so the real impl would no-op, but + * the symbol must resolve); xcecd_cleanup() unregisters. */ +int elim_register_sources(const elim_config_t* cfg) +{ + (void)cfg; + return 0; +} +void elim_unregister_sources(const elim_config_t* cfg) { (void)cfg; } + /* Referenced by xcecd_process_command()/xcecd_cleanup() -- never invoked at * runtime by this test (only xcecd_init()/xcecd_start() are called), but * their bodies are compiled unconditionally so the symbols must resolve. */