E2 Phase 2: external-license-resources 运维面 — stale 标记、last-updated 展示、xceadmin extres status

design.md 3.5 Phase 2(PEN D reason 专用名为可选项,复用 PREASON_NO_RESOURCE
已带资源名,跳过):

① esd stale 标记与 WARN
  - esd_source_t 增 stale_limit/last_ok_time;esd_source_is_stale(): 从未有值
    不算 stale、时钟超前钳到 0、严格大于(interval==limit 不抖动)、
    ESD_STALE_LIMIT_DEFAULT 150;fail-open 保持旧值不变
  - 采集失败首轮一次性 WARN,过限后每轮升级为 STALE WARN(带 age/limit/kept
    值),恢复即静默
  - status 文件 esd_status_write(): daemon/pid/started_at/generated_at/
    pushes ok/fail/sources[{name,last_value,last_ok_time,stale}],tmp+rename
    原子写;写失败仅状态转换时告警一次,绝不拖垮 poll 循环

② `xce resources` 展示 last-updated
  - controller worker.c: EXTERNAL 共享值被接受时盖 last_report——同时盖
    live resmgr 对象与 myconf 配置树副本(apiserver 定义 store 只由 join 时
    config push 供给,串行的是配置树,缺后者 CLI 永远显示 never)
  - scheduler core_events.c: RESOURCES_PUSH 原地更新拷贝 last_report(纯
    metadata,resobj.c 比较本就排除)
  - apiserver: model 带 last_report + 默认列(SelectFields 只投影默认列,
    缺失即 API/CLI 全链不可见);convert cResourceToModel 透传
  - apiserver handler.go: OnResourceLocationUpdated 对 host=="" 且定义带
    EXTERNAL(0x10) 的推送刷新 store 内 LastReport——config push 快照之外
    的活刷新;host 来源值与非 EXTERNAL 资源绝不触碰
  - xcecli: LAST_UPDATED 列(-l verbose 表格/详情/JSON 三态),0 显示 "-"

④ `xceadmin extres status`
  - 本机读 status 文件(远程读取留待 PR-2,需新 wire 协议;LAST_UPDATED
    已覆盖远程新鲜度语义)——--status-file 显式路径或
    /var/run/xce/esd-status.json 默认,无 controller 连接依赖
  - XceExtresStatusError 本地文件类错误;click default=None 装饰期冻结坑
    用调用期解析

TDD:test_extres_resvals +2(配置树盖戳/非 EXTERNAL 不盖戳)、test_esd_core
+4(stale 判式/状态文件)、apiserver models +2(默认列/json tag)、backend
integration +2(EXTERNAL 刷新/非 EXTERNAL 不刷新)、xceadmin +11(渲染/
本地读/容错)。全绿:controller 1360、scheduler 100、apiserver(cgo+nocgo)、
esd 11、xceadmin 385。

t1 E2E 实证:esd join 即推送→API last_report=join 时刻→CLI LAST_UPDATED
"Oct 27 13:52";破坏采集器后 60s 过限转 per-round STALE WARN + status
stale:true + fail-open 保值,恢复脚本即回 false;liccheck 作业提交→运行→
DONE,五 daemon 全健康。

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
lei.chen 2026-10-27 14:00:33 +00:00
parent 3eb1b9716d
commit 805288d747
20 changed files with 1179 additions and 47 deletions

View file

@ -18,6 +18,12 @@
* endpoint, so the seam under test is worker.c's list applier, the same * endpoint, so the seam under test is worker.c's list applier, the same
* XCE_TESTING-exposure convention as apply_join_state in * XCE_TESTING-exposure convention as apply_join_state in
* test_worker_join.c). * test_worker_join.c).
*
* external-license-resources Phase 2 adds rule 3: an EXTERNAL shared
* acceptance also stamps last_report on the myconf cluster-config tree's
* definition -- the apiserver's resource-definition store is fed ONLY by
* the join-time config push (myconf tree, not the live resmgr object), so
* without this the CLI's LAST_UPDATED column would stay "never" forever.
*/ */
/* rtti.h must come first — it defines 'var' and 'typeinfo_t' used by kvmgr.h */ /* rtti.h must come first — it defines 'var' and 'typeinfo_t' used by kvmgr.h */
@ -33,6 +39,7 @@
#include "cluster.h" #include "cluster.h"
#include "controller.capnp.h" #include "controller.capnp.h"
#include "kvmgr.h" #include "kvmgr.h"
#include "myconf.h"
#include "resobj.h" #include "resobj.h"
#include "worker.h" #include "worker.h"
#include "xce/types.h" #include "xce/types.h"
@ -52,6 +59,12 @@ int update_resval_list(cluster_t* c, worker_t* w, xce_resval_list_t* l,
typedef struct { typedef struct {
cluster_t c; cluster_t c;
var resmgr; var resmgr;
/* myconf fixture: one cluster_config_t owning a static resources[] tree
* (the config-push wire source, distinct from the resmgr objects). */
myconf_t myconf;
cluster_config_t ccfg;
xce_resource_t* cfg_res[2];
xce_resource_t cfg_defs[2];
} test_cluster_t; } test_cluster_t;
static test_cluster_t g_te; static test_cluster_t g_te;
@ -90,6 +103,23 @@ static void setup_resmgr(void)
g_te.c.resmgr = g_te.resmgr; g_te.c.resmgr = g_te.resmgr;
zarray_init(&g_te.c.sharedinst, 64, NULL); zarray_init(&g_te.c.sharedinst, 64, NULL);
/* the myconf tree mirrors the resmgr registrations by name (the live
* shape: cluster_build_resources applies the config tree INTO resmgr,
* so names match; objects are distinct allocations) */
{
static const char* names[2] = { "lic", "slots" };
for (int i = 0; i < 2; i++) {
memset(&g_te.cfg_defs[i], 0, sizeof(g_te.cfg_defs[i]));
g_te.cfg_defs[i].name = (char*)names[i];
g_te.cfg_res[i] = &g_te.cfg_defs[i];
}
g_te.ccfg.nres = 2;
g_te.ccfg.resources = g_te.cfg_res;
g_te.myconf.cluster_config = &g_te.ccfg;
g_te.c.myconf = &g_te.myconf;
}
} }
static void teardown_resmgr(void) static void teardown_resmgr(void)
@ -192,6 +222,69 @@ static void test_extres_nonshared_resval_refused_eperm(void)
free(applied_vals); free(applied_vals);
} }
/* Rule 3 (Phase 2): an EXTERNAL shared acceptance stamps last_report on the
* myconf cluster-config tree's definition with the current time — the
* config push the apiserver receives on (re)join serializes THAT tree, not
* the live resmgr object, so an unstamped tree would show "never" in the
* CLI's LAST_UPDATED column even while the daemon pushes normally. */
static void test_extres_external_accept_stamps_config_tree(void)
{
static const char* names[1] = { "lic" };
static const float values[1] = { 4.0f };
xce_resval_list_t* l;
time_t before;
time_t after;
setup_resmgr();
/* mark the definition EXTERNAL and confirm both copies start unstamped
* (config decode leaves last_report 0 for YAML-sourced definitions) */
{
xce_resource_t* live = NULL;
lock(g_te.resmgr, RTTI_LOCK_READ);
(void)key_find(g_te.resmgr, (void*)"lic", (void**)&live);
unlock(g_te.resmgr);
TEST_ASSERT_NOT_NULL(live);
live->flags |= XCE_RESF_EXTERNAL;
g_te.cfg_defs[0].flags |= XCE_RESF_EXTERNAL;
TEST_ASSERT_EQUAL_INT(0, live->last_report);
TEST_ASSERT_EQUAL_INT(0, g_te.cfg_defs[0].last_report);
}
time(&before);
l = make_resval_list(names, values, 1);
TEST_ASSERT_EQUAL_INT(0, update_resval_list(&g_te.c, NULL, l, NULL));
time(&after);
TEST_ASSERT_EQUAL_INT((int)before, (int)g_te.cfg_defs[0].last_report);
TEST_ASSERT_TRUE(g_te.cfg_defs[0].last_report >= before);
TEST_ASSERT_TRUE(g_te.cfg_defs[0].last_report <= after);
teardown_resmgr();
}
/* The stamp is EXTERNAL-gated: a plain SHARED (non-EXTERNAL) acceptance
* leaves the config tree untouched — last_report means "external value
* last accepted", and worker-sourced values must never set it. */
static void test_extres_plain_shared_accept_leaves_config_tree(void)
{
static const char* names[1] = { "lic" };
static const float values[1] = { 4.0f };
xce_resval_list_t* l;
setup_resmgr();
/* SHARED but NOT EXTERNAL */
l = make_resval_list(names, values, 1);
TEST_ASSERT_EQUAL_INT(0, update_resval_list(&g_te.c, NULL, l, NULL));
TEST_ASSERT_EQUAL_INT(0, g_te.cfg_defs[0].last_report);
teardown_resmgr();
}
/* ── suite entry point ─────────────────────────────────────────────── */ /* ── suite entry point ─────────────────────────────────────────────── */
void test_extres_resvals(void) void test_extres_resvals(void)
@ -199,4 +292,6 @@ void test_extres_resvals(void)
UnitySetTestFile(__FILE__); UnitySetTestFile(__FILE__);
RUN_TEST(test_extres_shared_resval_lands_in_sharedinst); RUN_TEST(test_extres_shared_resval_lands_in_sharedinst);
RUN_TEST(test_extres_nonshared_resval_refused_eperm); RUN_TEST(test_extres_nonshared_resval_refused_eperm);
RUN_TEST(test_extres_external_accept_stamps_config_tree);
RUN_TEST(test_extres_plain_shared_accept_leaves_config_tree);
} }

View file

@ -74,6 +74,35 @@ static int update_val_res(cluster_t* c, worker_t* w, xce_resource_t* r,
if (r->flags & XCE_RESF_SHARED) { if (r->flags & XCE_RESF_SHARED) {
/* update shared resource */ /* update shared resource */
array = &(c->sharedinst); array = &(c->sharedinst);
/* external-license-resources Phase 2: the descriptor's reserved
* last_report becomes the "external value freshness" stamp -- the
* extres daemon's accepted pushes are the only writers of an
* EXTERNAL shared value, so every acceptance refreshes it and
* `xce resources` / a stale read can tell "reported recently"
* from "kept old value" (design.md 3.5 运维面). The apiserver's
* definition store is fed by the join-time CONFIG push, which
* serializes c->myconf->cluster_config -- a DIFFERENT copy from
* this live resmgr object -- so that tree's same-named definition
* gets the same stamp or the CLI's LAST_UPDATED column would stay
* "never" between apiserver (re)joins. */
if (r->flags & XCE_RESF_EXTERNAL) {
time_t now = time(NULL);
r->last_report = now;
if ((c->myconf != NULL) && (c->myconf->cluster_config != NULL)) {
cluster_config_t* cc = c->myconf->cluster_config;
for (int i = 0; i < cc->nres; i++) {
xce_resource_t* cr = cc->resources[i];
if ((cr != NULL) && (cr->name != NULL)
&& (strcmp(cr->name, r->name) == 0)) {
cr->last_report = now;
break;
}
}
}
}
} else if (w == NULL) { } else if (w == NULL) {
/* external-license-resources Phase 1: a NULL worker is the extres /* external-license-resources Phase 1: a NULL worker is the extres
* daemon (HostRole_extres join, no workermgr slot). It may only * daemon (HostRole_extres join, no workermgr slot). It may only

View file

@ -29,7 +29,10 @@ typedef struct esd_source_s {
char* name; /* resource name, same as resources.yaml */ char* name; /* resource name, same as resources.yaml */
char* command; /* script collector: the command to run */ char* command; /* script collector: the command to run */
int interval; /* poll interval, seconds (0: use the daemon default) */ int interval; /* poll interval, seconds (0: use the daemon default) */
long stale_limit; /* seconds a kept value stays trustworthy (0: use
* the daemon default); design.md 3.4 */
double last_value; /* last successfully-parsed value */ double last_value; /* last successfully-parsed value */
time_t last_ok_time; /* when last_value was collected (0: never) */
bool have_value; /* last_value is valid (any push has succeeded) */ bool have_value; /* last_value is valid (any push has succeeded) */
bool last_failed; /* last poll failed (stale-value policy input) */ bool last_failed; /* last poll failed (stale-value policy input) */
} esd_source_t; } esd_source_t;
@ -39,10 +42,22 @@ typedef struct esd_config_s {
int controller_port; int controller_port;
char* peers; /* --peers style candidate list, or NULL */ char* peers; /* --peers style candidate list, or NULL */
int poll_interval; /* daemon default poll interval, seconds */ int poll_interval; /* daemon default poll interval, seconds */
long stale_limit; /* daemon default stale limit, seconds */
char* status_file; /* status JSON path (NULL: no file) */
int nsrc; int nsrc;
esd_source_t* src; /* array of nsrc sources */ esd_source_t* src; /* array of nsrc sources */
} esd_config_t; } esd_config_t;
/* Daemon-wide counters and identity for the status file (Phase 2,
* design.md 3.5 "xceadmin extres status"): the daemon fills one and hands
* it to esd_status_write each poll round. */
typedef struct esd_stats_s {
time_t started_at; /* daemon start, epoch seconds */
time_t now; /* snapshot time (filled by esd_status_write when 0) */
long pushes_ok; /* successful resval pushes since start */
long pushes_fail; /* failed pushes since start */
} esd_stats_t;
/* /*
* Parse esd.yaml: * Parse esd.yaml:
* *
@ -87,4 +102,29 @@ extern double esd_script_collect(const char* command);
* it is killed (default 10s; main.c overrides from the CLI). */ * it is killed (default 10s; main.c overrides from the CLI). */
extern int esd_script_timeout_ms; extern int esd_script_timeout_ms;
/* Default stale limit for sources (and the config default) -- design.md
* 3.4's example: 150s. A source whose collector keeps failing past this
* many seconds since its last good value reports stale=true in the status
* file (the daemon itself keeps the last value -- fail-open). */
#define ESD_STALE_LIMIT_DEFAULT 150
/* Stale predicate (Phase 2): true when the source's last good value is
* older than its stale limit -- `now` vs src->last_ok_time, the source's
* own stale_limit when set, else the daemon default. A source with no
* value yet is never stale (there is nothing being kept alive; admission
* already treats the pool as empty through the normal NO_RESOURCE path).
* A last_ok_time in the future (clock skew) is not stale. */
extern bool esd_source_is_stale(const esd_source_t* src, time_t now,
long default_limit);
/* Write the daemon status JSON (Phase 2, "xceadmin extres status"): one
* object with the daemon identity, push counters, and per-source rows
* (name, last_value, last_ok_time, stale). `stats->now == 0` is filled
* with time(NULL). Written atomically (tmp + rename) so a reader never
* sees a partial file. Returns 0, or -errno-shaped negative (the caller
* logs; the status file is best-effort observability and must never take
* the poll loop down). */
extern int esd_status_write(const char* path, const esd_config_t* cfg,
esd_stats_t* stats);
#endif #endif

View file

@ -5,6 +5,7 @@
esd_srcs = [ esd_srcs = [
'src/esd_config.c', 'src/esd_config.c',
'src/esd_script.c', 'src/esd_script.c',
'src/esd_status.c',
'src/esd.c', 'src/esd.c',
] ]

View file

@ -27,6 +27,7 @@ typedef struct esd_daemon_s {
const char* from; /* join identity ("xceesd") */ const char* from; /* join identity ("xceesd") */
int running; /* 0 once the main loop should exit */ int running; /* 0 once the main loop should exit */
xce_cluster_config_t cluster_cfg; /* validated peers list (HA mode) */ xce_cluster_config_t cluster_cfg; /* validated peers list (HA mode) */
esd_stats_t stats; /* status-file counters (Phase 2) */
} esd_daemon_t; } esd_daemon_t;
static esd_daemon_t g_esd; static esd_daemon_t g_esd;
@ -61,8 +62,10 @@ int esd_push_value(const char* name, double value)
rc = xcecli_update_resvals(g_esd.apictx, 1, &valptr); rc = xcecli_update_resvals(g_esd.apictx, 1, &valptr);
if (rc < 0) { if (rc < 0) {
ERROR("esd push %s=%f failed (rc=%d)", name, value, rc); ERROR("esd push %s=%f failed (rc=%d)", name, value, rc);
g_esd.stats.pushes_fail++;
} else { } else {
TRACE("esd pushed %s=%f", name, value); TRACE("esd pushed %s=%f", name, value);
g_esd.stats.pushes_ok++;
} }
return rc; return rc;
@ -72,11 +75,18 @@ int esd_push_value(const char* name, double value)
* push only what CHANGED (the debounce -- design.md step 6 "轮询去抖"). * push only what CHANGED (the debounce -- design.md step 6 "轮询去抖").
* A failed collection keeps the previous value and does not push (fail- * A failed collection keeps the previous value and does not push (fail-
* open: the controller keeps scheduling against the last known good * open: the controller keeps scheduling against the last known good
* count; design.md 3.4 stale policy default). */ * count; design.md 3.4 stale policy default).
*
* Phase 2: every source records last_ok_time on a good collect (the
* stale predicate's input, exposed via the status file); a failure past
* the source's stale limit escalates from the one-shot WARN to a per-
* round stale WARN -- the operator sees a kept-old-value daemon without
* having to guess from the absence of "pushed" lines. */
int esd_poll_round(void) int esd_poll_round(void)
{ {
int i; int i;
int pushed = 0; int pushed = 0;
time_t now = time(NULL);
for (i = 0; i < g_esd.cfg.nsrc; i++) { for (i = 0; i < g_esd.cfg.nsrc; i++) {
esd_source_t* src = &(g_esd.cfg.src[i]); esd_source_t* src = &(g_esd.cfg.src[i]);
@ -89,12 +99,22 @@ int esd_poll_round(void)
WARN("source %s: collection failed -- keeping last known " WARN("source %s: collection failed -- keeping last known "
"value %f (fail-open)", "value %f (fail-open)",
src->name, src->have_value ? src->last_value : 0.0); src->name, src->have_value ? src->last_value : 0.0);
} else if (esd_source_is_stale(src, now,
g_esd.cfg.stale_limit)) {
WARN("source %s: STALE for %lds (limit %lds) -- last good "
"value %f kept (fail-open); check the collector",
src->name,
(long)(now - src->last_ok_time),
(src->stale_limit > 0) ? src->stale_limit
: g_esd.cfg.stale_limit,
src->last_value);
} }
src->last_failed = true; src->last_failed = true;
continue; continue;
} }
src->last_failed = false; src->last_failed = false;
src->last_ok_time = now;
if (src->have_value && (src->last_value == v)) { if (src->have_value && (src->last_value == v)) {
continue; /* unchanged -- the debounce */ continue; /* unchanged -- the debounce */
@ -262,6 +282,26 @@ int esd_run(void)
(void)esd_poll_round(); (void)esd_poll_round();
/* Phase 2: refresh the status file every round (best-effort; the
* poll loop must survive a bad path). */
if (g_esd.cfg.status_file != NULL) {
/* one WARN per failure would spam a broken path every round;
* log on the transition only */
static bool status_warned = false;
int src = esd_status_write(g_esd.cfg.status_file, &g_esd.cfg,
&g_esd.stats);
if (src < 0) {
if (!status_warned) {
WARN("esd: cannot write status file %s (rc=%d) -- "
"continuing without it",
g_esd.cfg.status_file, src);
status_warned = true;
}
} else {
status_warned = false;
}
}
/* sleep in 1s slices so a signal stop lands promptly */ /* sleep in 1s slices so a signal stop lands promptly */
for (i = 0; (i < g_esd.cfg.poll_interval) && g_esd.running; i++) { for (i = 0; (i < g_esd.cfg.poll_interval) && g_esd.running; i++) {
sleep(1); sleep(1);
@ -282,7 +322,8 @@ void esd_stop(void)
void esd_init(esd_config_t* cfg, const char* from) void esd_init(esd_config_t* cfg, const char* from)
{ {
memset(&g_esd, 0, sizeof(g_esd)); memset(&g_esd, 0, sizeof(g_esd));
g_esd.cfg = *cfg; g_esd.cfg = *cfg;
g_esd.from = from; g_esd.from = from;
g_esd.running = 1; g_esd.running = 1;
g_esd.stats.started_at = time(NULL);
} }

View file

@ -17,13 +17,16 @@ typedef struct yaml_esd_source_s {
char* name; char* name;
char* collector; char* collector;
char* command; char* command;
long interval; /* 0 = daemon default */ long interval; /* 0 = daemon default */
long stale_limit; /* 0 = daemon default (Phase 2, design.md 3.4) */
} yaml_esd_source_t; } yaml_esd_source_t;
typedef struct yaml_esd_config_s { typedef struct yaml_esd_config_s {
char* controller; char* controller;
char* peers; char* peers;
long poll_interval; long poll_interval;
long stale_limit; /* 0 = ESD_STALE_LIMIT_DEFAULT */
char* status_file; /* NULL = no status file */
yaml_esd_source_t* resources; yaml_esd_source_t* resources;
unsigned resources_count; unsigned resources_count;
} yaml_esd_config_t; } yaml_esd_config_t;
@ -37,6 +40,8 @@ static const cyaml_schema_field_t esd_source_fields_schema[] = {
command, 1, CYAML_UNLIMITED), command, 1, CYAML_UNLIMITED),
CYAML_FIELD_INT("interval", CYAML_FLAG_OPTIONAL, yaml_esd_source_t, CYAML_FIELD_INT("interval", CYAML_FLAG_OPTIONAL, yaml_esd_source_t,
interval), interval),
CYAML_FIELD_INT("stale_limit", CYAML_FLAG_OPTIONAL, yaml_esd_source_t,
stale_limit),
CYAML_FIELD_END CYAML_FIELD_END
}; };
@ -52,6 +57,10 @@ static const cyaml_schema_field_t esd_config_fields_schema[] = {
peers, 1, CYAML_UNLIMITED), peers, 1, CYAML_UNLIMITED),
CYAML_FIELD_INT("poll_interval", CYAML_FLAG_OPTIONAL, yaml_esd_config_t, CYAML_FIELD_INT("poll_interval", CYAML_FLAG_OPTIONAL, yaml_esd_config_t,
poll_interval), poll_interval),
CYAML_FIELD_INT("stale_limit", CYAML_FLAG_OPTIONAL, yaml_esd_config_t,
stale_limit),
CYAML_FIELD_STRING_PTR("status_file", CYAML_FLAG_OPTIONAL,
yaml_esd_config_t, status_file, 1, CYAML_UNLIMITED),
CYAML_FIELD_SEQUENCE("resources", CYAML_FLAG_POINTER, yaml_esd_config_t, CYAML_FIELD_SEQUENCE("resources", CYAML_FLAG_POINTER, yaml_esd_config_t,
resources, &esd_source_schema, 0, CYAML_UNLIMITED), resources, &esd_source_schema, 0, CYAML_UNLIMITED),
CYAML_FIELD_END CYAML_FIELD_END
@ -173,9 +182,23 @@ int esd_config_load_path(const char* dir, const char* name,
cfg.peers = (ycfg->peers != NULL) ? strdup(ycfg->peers) : NULL; cfg.peers = (ycfg->peers != NULL) ? strdup(ycfg->peers) : NULL;
cfg.poll_interval = (int)ycfg->poll_interval; cfg.poll_interval = (int)ycfg->poll_interval;
cfg.stale_limit = ycfg->stale_limit;
cfg.status_file = (ycfg->status_file != NULL) ? strdup(ycfg->status_file)
: NULL;
cfg.nsrc = (int)ycfg->resources_count; cfg.nsrc = (int)ycfg->resources_count;
cfg.src = NULL; cfg.src = NULL;
if ((cfg.peers == NULL) && (ycfg->peers != NULL)) {
esd_config_fini(&cfg);
cyaml_free(&cyaml_cfg, &esd_config_schema, ycfg, 0);
return -ENOMEM;
}
if ((cfg.status_file == NULL) && (ycfg->status_file != NULL)) {
esd_config_fini(&cfg);
cyaml_free(&cyaml_cfg, &esd_config_schema, ycfg, 0);
return -ENOMEM;
}
if (cfg.nsrc > 0) { if (cfg.nsrc > 0) {
cfg.src = (esd_source_t*)calloc((size_t)cfg.nsrc, cfg.src = (esd_source_t*)calloc((size_t)cfg.nsrc,
sizeof(esd_source_t)); sizeof(esd_source_t));
@ -195,9 +218,10 @@ int esd_config_load_path(const char* dir, const char* name,
break; break;
} }
cfg.src[i].name = strdup(ys->name); cfg.src[i].name = strdup(ys->name);
cfg.src[i].command = strdup(ys->command); cfg.src[i].command = strdup(ys->command);
cfg.src[i].interval = (int)ys->interval; cfg.src[i].interval = (int)ys->interval;
cfg.src[i].stale_limit = ys->stale_limit;
if ((cfg.src[i].name == NULL) || (cfg.src[i].command == NULL)) { if ((cfg.src[i].name == NULL) || (cfg.src[i].command == NULL)) {
rc = -ENOMEM; rc = -ENOMEM;
@ -275,6 +299,7 @@ void esd_config_fini(esd_config_t* cfg)
free(cfg->controller_host); free(cfg->controller_host);
free(cfg->peers); free(cfg->peers);
free(cfg->status_file);
for (i = 0; i < cfg->nsrc; i++) { for (i = 0; i < cfg->nsrc; i++) {
free(cfg->src[i].name); free(cfg->src[i].name);

123
src/esd/src/esd_status.c Normal file
View file

@ -0,0 +1,123 @@
/* esd_status.c -- xceesd status file (external-license-resources Phase 2,
* design.md 3.5 "xceadmin extres status"). The daemon writes one JSON
* snapshot per poll round to a configured path; xceadmin reads it on the
* daemon host. No new wire opcode: the PR-2 proxy formalizes a status
* channel later, and a local file keeps this observability surface
* strictly read-only and crash-safe.
*
* Atomicity: write to <path>.tmp then rename(2) -- a concurrent reader
* (xceadmin) either sees the previous complete file or the new one, never
* a partial write (the fake-license E2E's lic_state lesson: a reader
* observing a truncated file mistakes it for data loss).
*/
#include <errno.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <time.h>
#include <unistd.h>
#include "esd.h"
bool esd_source_is_stale(const esd_source_t* src, time_t now,
long default_limit)
{
long limit;
long age;
if ((src == NULL) || (!src->have_value) || (src->last_ok_time == 0)) {
return false;
}
/* Future last_ok_time (caller clock skew): age would go negative and
* read as fresh -- that is the honest reading, keep it by clamping. */
age = (now > src->last_ok_time) ? (long)(now - src->last_ok_time) : 0;
limit = (src->stale_limit > 0) ? src->stale_limit : default_limit;
if (limit <= 0) {
limit = ESD_STALE_LIMIT_DEFAULT;
}
return age > limit;
}
int esd_status_write(const char* path, const esd_config_t* cfg,
esd_stats_t* stats)
{
char tmp[1024];
FILE* f;
int i;
time_t now;
int rc = 0;
if ((path == NULL) || (cfg == NULL) || (stats == NULL)) {
return -EINVAL;
}
now = (stats->now != 0) ? stats->now : time(NULL);
snprintf(tmp, sizeof(tmp), "%s.tmp", path);
f = fopen(tmp, "w");
if (f == NULL) {
return -errno;
}
if (fprintf(f,
"{\n"
" \"daemon\": \"xceesd\",\n"
" \"pid\": %ld,\n"
" \"started_at\": %ld,\n"
" \"generated_at\": %ld,\n"
" \"pushes_ok\": %ld,\n"
" \"pushes_fail\": %ld,\n"
" \"sources\": [\n",
(long)getpid(), (long)stats->started_at, (long)now,
stats->pushes_ok, stats->pushes_fail) < 0) {
rc = -EIO;
goto out;
}
for (i = 0; i < cfg->nsrc; i++) {
const esd_source_t* src = &(cfg->src[i]);
bool stale =
esd_source_is_stale(src, now, cfg->stale_limit);
char valbuf[64];
if (src->have_value) {
snprintf(valbuf, sizeof(valbuf), "%.17g", src->last_value);
} else {
snprintf(valbuf, sizeof(valbuf), "null");
}
if (fprintf(f,
" {\"name\": \"%s\", \"last_value\": %s, "
"\"last_ok_time\": %ld, \"stale\": %s}%s\n",
(src->name != NULL) ? src->name : "",
valbuf,
(long)src->last_ok_time, stale ? "true" : "false",
(i + 1 < cfg->nsrc) ? "," : "") < 0) {
rc = -EIO;
goto out;
}
}
if (fprintf(f, " ]\n}\n") < 0) {
rc = -EIO;
goto out;
}
out:
if (fclose(f) != 0) {
rc = (rc == 0) ? -EIO : rc;
}
if (rc == 0) {
if (rename(tmp, path) != 0) {
rc = -errno;
}
}
if (rc != 0) {
unlink(tmp); /* never leave the .tmp behind for a reader to trip on */
}
return rc;
}

View file

@ -23,6 +23,9 @@
* the spawn half (esd_script_collect) is exercised by the t1 E2E with a * the spawn half (esd_script_collect) is exercised by the t1 E2E with a
* real fake-license script, not here. */ * real fake-license script, not here. */
#include "../src/esd_script.c" #include "../src/esd_script.c"
/* White-box: the stale predicate and the status-file writer (Phase 2,
* design.md 3.5 "运维面"). */
#include "../src/esd_status.c"
static char g_tmpdir[256]; static char g_tmpdir[256];
@ -58,6 +61,35 @@ static void write_file(const char* dir, const char* name, const char* content)
fclose(f); fclose(f);
} }
static char* slurp(const char* path)
{
FILE* f;
long len;
char* buf;
f = fopen(path, "r");
if (f == NULL) {
return NULL;
}
fseek(f, 0, SEEK_END);
len = ftell(f);
fseek(f, 0, SEEK_SET);
buf = (char*)malloc((size_t)len + 1);
if (buf == NULL) {
fclose(f);
return NULL;
}
if (fread(buf, 1, (size_t)len, f) != (size_t)len) {
fclose(f);
free(buf);
return NULL;
}
buf[len] = '\0';
fclose(f);
return buf;
}
/* ── esd_config_load ─────────────────────────────────────────────────── */ /* ── esd_config_load ─────────────────────────────────────────────────── */
static void test_config_load_full_file(void) static void test_config_load_full_file(void)
@ -185,6 +217,162 @@ static void test_parse_output_garbage_is_nan(void)
TEST_ASSERT_TRUE(isnan(v)); TEST_ASSERT_TRUE(isnan(v));
} }
/* ── Phase 2: stale_limit config parsing ────────────────────────────── */
static void test_config_stale_limit_parse(void)
{
esd_config_t cfg;
int rc;
memset(&cfg, 0, sizeof(cfg));
/* per-source override wins; a source without one takes the daemon
* default; the daemon default itself is optional with a sane built-in
* (design.md 3.4: stale_limit in esd.yaml, fail-open keep-old-value). */
write_file(g_tmpdir, "esd.yaml",
"controller: 172.31.1.30:9990\n"
"poll_interval: 10\n"
"stale_limit: 200\n"
"resources:\n"
" - name: lic_a\n"
" collector: script\n"
" command: /bin/a.sh\n"
" stale_limit: 60\n"
" - name: lic_b\n"
" collector: script\n"
" command: /bin/b.sh\n");
rc = esd_config_load_path(g_tmpdir, "esd.yaml", &cfg);
TEST_ASSERT_EQUAL_INT(0, rc);
TEST_ASSERT_EQUAL_INT(200, cfg.stale_limit);
TEST_ASSERT_EQUAL_INT(60, cfg.src[0].stale_limit);
TEST_ASSERT_EQUAL_INT(0, cfg.src[1].stale_limit); /* 0 = use default */
esd_config_fini(&cfg);
}
/* ── Phase 2: stale predicate ───────────────────────────────────────── */
static void test_stale_predicate(void)
{
esd_source_t src;
memset(&src, 0, sizeof(src));
src.name = (char*)"lic";
src.have_value = true;
src.last_value = 3.0;
/* Never report a value -> nothing to call stale; admission treats the
* pool as 0 through the normal NO_RESOURCE path. */
src.last_ok_time = 0;
TEST_ASSERT_FALSE(esd_source_is_stale(&src, 100, 60));
/* Fresh success (10s ago, limit 60) -> not stale. */
src.last_ok_time = 90;
TEST_ASSERT_FALSE(esd_source_is_stale(&src, 100, 60));
/* Exactly at the limit -> not stale (strictly-greater rule, so an
* interval == stale_limit config never flaps). */
src.last_ok_time = 40;
TEST_ASSERT_FALSE(esd_source_is_stale(&src, 100, 60));
/* Past the limit -> stale. */
src.last_ok_time = 39;
TEST_ASSERT_TRUE(esd_source_is_stale(&src, 100, 60));
/* A source whose own stale_limit override is set uses it. */
src.stale_limit = 10;
TEST_ASSERT_TRUE(esd_source_is_stale(&src, 100, 60));
src.last_ok_time = 95;
TEST_ASSERT_FALSE(esd_source_is_stale(&src, 100, 60));
/* last_ok_time newer than `now` (clock skew between test harness and
* caller) must not read as stale. */
src.last_ok_time = 105;
TEST_ASSERT_FALSE(esd_source_is_stale(&src, 100, 60));
}
/* ── Phase 2: status file ───────────────────────────────────────────── */
static void test_status_write_basic(void)
{
esd_config_t cfg;
esd_stats_t stats;
char path[512];
char* text;
time_t now = time(NULL);
char expect_lvt[128];
memset(&cfg, 0, sizeof(cfg));
cfg.nsrc = 1;
cfg.src = (esd_source_t*)calloc(1, sizeof(esd_source_t));
TEST_ASSERT_NOT_NULL(cfg.src);
cfg.src[0].name = (char*)"extlic";
cfg.src[0].have_value = true;
cfg.src[0].last_value = 3.0;
/* fresh success: last good value 5s ago, limit default 150 -> not
* stale (the marks-stale test below covers the other side) */
cfg.src[0].last_ok_time = now - 5;
cfg.stale_limit = ESD_STALE_LIMIT_DEFAULT;
memset(&stats, 0, sizeof(stats));
stats.started_at = 1000;
stats.pushes_ok = 7;
stats.pushes_fail = 1;
snprintf(path, sizeof(path), "%s/status.json", g_tmpdir);
TEST_ASSERT_EQUAL_INT(0, esd_status_write(path, &cfg, &stats));
text = slurp(path);
TEST_ASSERT_NOT_NULL(text);
/* The fields xceadmin extres status reads, and the stale flag driven
* by the same predicate the tests above pin. */
TEST_ASSERT_TRUE(strstr(text, "\"daemon\": \"xceesd\"") != NULL);
TEST_ASSERT_TRUE(strstr(text, "\"name\": \"extlic\"") != NULL);
snprintf(expect_lvt, sizeof(expect_lvt), "\"last_value\": %.17g", 3.0);
TEST_ASSERT_TRUE(strstr(text, expect_lvt) != NULL);
TEST_ASSERT_TRUE(strstr(text, "\"last_ok_time\": ") != NULL);
TEST_ASSERT_TRUE(strstr(text, "\"stale\": false") != NULL);
TEST_ASSERT_TRUE(strstr(text, "\"pushes_ok\": 7") != NULL);
TEST_ASSERT_TRUE(strstr(text, "\"pushes_fail\": 1") != NULL);
free(text);
free(cfg.src);
}
static void test_status_write_marks_stale(void)
{
esd_config_t cfg;
esd_stats_t stats;
char path[512];
char* text;
time_t now = time(NULL);
memset(&cfg, 0, sizeof(cfg));
cfg.nsrc = 1;
cfg.src = (esd_source_t*)calloc(1, sizeof(esd_source_t));
TEST_ASSERT_NOT_NULL(cfg.src);
cfg.src[0].name = (char*)"extlic";
cfg.src[0].have_value = true;
cfg.src[0].last_value = 2.0;
/* last success 1 hour ago, limit 150s -> stale. */
cfg.src[0].last_ok_time = now - 3600;
cfg.src[0].stale_limit = 150;
memset(&stats, 0, sizeof(stats));
snprintf(path, sizeof(path), "%s/status.json", g_tmpdir);
TEST_ASSERT_EQUAL_INT(0, esd_status_write(path, &cfg, &stats));
text = slurp(path);
TEST_ASSERT_NOT_NULL(text);
TEST_ASSERT_TRUE(strstr(text, "\"stale\": true") != NULL);
free(text);
free(cfg.src);
}
int main(void) int main(void)
{ {
UNITY_BEGIN(); UNITY_BEGIN();
@ -196,6 +384,10 @@ int main(void)
RUN_TEST(test_parse_output_name_value); RUN_TEST(test_parse_output_name_value);
RUN_TEST(test_parse_output_whitespace_and_negative); RUN_TEST(test_parse_output_whitespace_and_negative);
RUN_TEST(test_parse_output_garbage_is_nan); RUN_TEST(test_parse_output_garbage_is_nan);
RUN_TEST(test_config_stale_limit_parse);
RUN_TEST(test_stale_predicate);
RUN_TEST(test_status_write_basic);
RUN_TEST(test_status_write_marks_stale);
return UNITY_END(); return UNITY_END();
} }

View file

@ -286,12 +286,13 @@ func cQueueToModel(queue *C.xce_queue_t) *models.QueueInfo {
// cResourceToModel extracts C xce_resource_t fields and maps them to ResourceDefinitionInfo. // cResourceToModel extracts C xce_resource_t fields and maps them to ResourceDefinitionInfo.
func cResourceToModel(res *C.xce_resource_t) *models.ResourceDefinitionInfo { func cResourceToModel(res *C.xce_resource_t) *models.ResourceDefinitionInfo {
return rawResourceToModel(rawResourceData{ return rawResourceToModel(rawResourceData{
name: goString(res.name), name: goString(res.name),
desc: goString(res.desc), desc: goString(res.desc),
valType: int(res.valtype), valType: int(res.valtype),
ordType: int(res.ordtype), ordType: int(res.ordtype),
flags: int(res.flags), flags: int(res.flags),
interval: int(res.interval), interval: int(res.interval),
lastReport: int64(res.last_report),
}) })
} }

View file

@ -578,12 +578,13 @@ type rawQueueData struct {
// rawResourceData mirrors the C xce_resource_t fields used by the Go model. // rawResourceData mirrors the C xce_resource_t fields used by the Go model.
type rawResourceData struct { type rawResourceData struct {
name string name string
desc string desc string
valType int valType int
ordType int ordType int
flags int flags int
interval int interval int
lastReport int64
} }
// interactiveURLFromExdata extracts the interactive session URL from a job // interactiveURLFromExdata extracts the interactive session URL from a job
@ -828,6 +829,7 @@ func rawResourceToModel(r rawResourceData) *models.ResourceDefinitionInfo {
OrderType: r.ordType, OrderType: r.ordType,
Flag: r.flags, Flag: r.flags,
Interval: r.interval, Interval: r.interval,
LastReport: r.lastReport,
} }
} }

View file

@ -5,6 +5,7 @@ import (
"apiserver/store" "apiserver/store"
logger "apiserver/utils" logger "apiserver/utils"
"sync" "sync"
"time"
"go.uber.org/zap" "go.uber.org/zap"
) )
@ -249,6 +250,23 @@ func (h *DefaultHandler) OnResourceLocationUpdated(res *models.ResourceDistribut
key := res.ResourceName + ":" + res.Location key := res.ResourceName + ":" + res.Location
store.ResourceLocations.Put(key, *res) store.ResourceLocations.Put(key, *res)
// external-license-resources Phase 2: the definition store is fed ONLY
// by the join-time config push, whose snapshot carries whatever the
// config tree had at push time -- so an EXTERNAL pool's last_report
// would stay 0 ("never") for the whole apiserver process lifetime even
// while the extres daemon pushes normally. A shared-value push (the
// daemon's landing shape: Location "") for an EXTERNAL definition is
// exactly an accepted external report at the controller, so refresh the
// stamp here. Host-sourced values (Location != "") and non-EXTERNAL
// resources must not touch it: last_report means "external value last
// accepted", and worker reports are a different freshness domain.
if res.Location == "" {
if def, ok := store.Resources.Get(res.ResourceName); ok && (def.Flag&models.EXTERNAL) != 0 {
def.LastReport = time.Now().Unix()
store.Resources.Put(def.ResourceName, def)
}
}
// resource-value-verification task 4.2 (Requirements 5.1, 5.2, 5.4): trace // resource-value-verification task 4.2 (Requirements 5.1, 5.2, 5.4): trace
// the stored value at its point of application, mirroring the scheduler's // the stored value at its point of application, mirroring the scheduler's
// sibling trace log added in task 4.1 (core_events.c). One typed "value" // sibling trace log added in task 4.1 (core_events.c). One typed "value"

View file

@ -8,6 +8,7 @@ import (
"apiserver/utils" "apiserver/utils"
"sync/atomic" "sync/atomic"
"testing" "testing"
"time"
"go.uber.org/zap" "go.uber.org/zap"
) )
@ -516,3 +517,71 @@ func TestIntegration_CrossFeature_SequentialConfigPushes(t *testing.T) {
t.Errorf("heartbeatIntervalSecs = %d, want 60", hb) t.Errorf("heartbeatIntervalSecs = %d, want 60", hb)
} }
} }
// --- 5.6 Phase 2 (external-license-resources): a shared-value push refreshes
// the stored EXTERNAL definition's last_report. The definition store is fed
// only by the join-time config push, whose snapshot carries whatever the
// config tree had at push time — without this refresh the CLI's LAST_UPDATED
// column would show "never" for the whole apiserver process lifetime even
// while the extres daemon pushes normally. The daemon landing shape for a
// cluster-level value is Location=="" (host:"" on the wire). Worker-sourced
// host values must NOT touch the stamp. ---
func TestIntegration_ExternalSharedPushRefreshesDefinitionLastReport(t *testing.T) {
resetIntegrationStores()
h := &DefaultHandler{}
// config push seeds the definition store: extlic is EXTERNAL (0x10),
// mem is a plain host-based resource.
dispatchConfigPush(h, nil, []models.ResourceDefinitionInfo{
{ResourceName: "extlic", Flag: 0x80 | 0x10, LastReport: 0},
{ResourceName: "mem", Flag: 0x80, LastReport: 0},
}, nil, &models.ClusterInfo{ClusterName: "c1"})
before := time.Now().Unix()
// daemon-shaped shared push for extlic (Location "")
h.OnResourceLocationUpdated(&models.ResourceDistributionInfo{
ResourceName: "extlic", Location: "", NumericValue: 4.0,
ValueType: int(models.NUMERIC), IsDynamic: true,
})
got, ok := store.Resources.Get("extlic")
if !ok {
t.Fatal("extlic definition should exist after config push")
}
if got.LastReport < before {
t.Errorf("extlic LastReport = %d, want >= %d (refreshed by the shared push)", got.LastReport, before)
}
// host-sourced push for mem must not stamp its definition
h.OnResourceLocationUpdated(&models.ResourceDistributionInfo{
ResourceName: "mem", Location: "node01", NumericValue: 65536.0,
ValueType: int(models.NUMERIC), IsDynamic: true,
})
gotMem, _ := store.Resources.Get("mem")
if gotMem.LastReport != 0 {
t.Errorf("mem LastReport = %d, want 0 (host values never stamp)", gotMem.LastReport)
}
}
// The refresh only applies to EXTERNAL definitions: a shared push for a
// non-EXTERNAL resource (e.g. a dynamic shared pool reported by workers)
// leaves last_report untouched.
func TestIntegration_SharedPushDoesNotStampNonExternalDefinition(t *testing.T) {
resetIntegrationStores()
h := &DefaultHandler{}
dispatchConfigPush(h, nil, []models.ResourceDefinitionInfo{
{ResourceName: "plainshared", Flag: 0x80 | 0x08, LastReport: 0},
}, nil, &models.ClusterInfo{ClusterName: "c1"})
h.OnResourceLocationUpdated(&models.ResourceDistributionInfo{
ResourceName: "plainshared", Location: "", NumericValue: 2.0,
ValueType: int(models.NUMERIC), IsDynamic: true,
})
got, _ := store.Resources.Get("plainshared")
if got.LastReport != 0 {
t.Errorf("plainshared LastReport = %d, want 0 (not EXTERNAL)", got.LastReport)
}
}

View file

@ -67,6 +67,10 @@ var RESOURCE_DEFINITION_DEFAULT_COLUMN = []string{
"order_type", "order_type",
"flag", "flag",
"interval", "interval",
// external-license-resources Phase 2: SelectFields drops row keys not
// listed here, so the external value's last-report timestamp must be a
// default column or it never reaches API clients / the CLI.
"last_report",
} }
var ResourceFieldMappings = map[string]string{ var ResourceFieldMappings = map[string]string{
@ -77,8 +81,12 @@ var ResourceFieldMappings = map[string]string{
var ResourcesAvailableTypes = []string{"static", "dynamic"} var ResourcesAvailableTypes = []string{"static", "dynamic"}
const ( const (
BUILTIN = 0x01 BUILTIN = 0x01
DYNAMIC = 0x02 DYNAMIC = 0x02
// external-license-resources: EXTERNAL=0x10 mirrors C's XCE_RESF_EXTERNAL
// (src/include/xce/types.h) — the value comes from an external daemon,
// not from workers.
EXTERNAL = 0x10
RELEASE = 0x20 RELEASE = 0x20
CONSUMABLE = 0x80 CONSUMABLE = 0x80
) )
@ -132,6 +140,9 @@ type ResourceDefinitionInfo struct {
OrderType int `json:"order_type"` OrderType int `json:"order_type"`
Flag int `json:"flag"` Flag int `json:"flag"`
Interval int `json:"interval"` Interval int `json:"interval"`
// external-license-resources Phase 2: epoch seconds the external value
// was last accepted by the controller (0 = never / not external).
LastReport int64 `json:"last_report"`
} }
var RESOURCE_DISTRIBUTION_SUPPORT_SORT = []string{ var RESOURCE_DISTRIBUTION_SUPPORT_SORT = []string{

View file

@ -0,0 +1,47 @@
package models
import (
"encoding/json"
"testing"
)
// external-license-resources Phase 2: last_report must be part of the default
// column list for /resources/definition — SelectFields drops any row key not
// listed there, so without it the API response (and the CLI LAST_UPDATED
// column) never sees the timestamp even though the model carries it.
func TestResourceDefinitionDefaultColumnIncludesLastReport(t *testing.T) {
found := false
for _, col := range RESOURCE_DEFINITION_DEFAULT_COLUMN {
if col == "last_report" {
found = true
break
}
}
if !found {
t.Errorf("RESOURCE_DEFINITION_DEFAULT_COLUMN = %v, missing \"last_report\"", RESOURCE_DEFINITION_DEFAULT_COLUMN)
}
}
// ResourceDefinitionInfo must serialize last_report with the json tag the CLI
// model (xcecli/models/resource.go) unmarshals.
func TestResourceDefinitionInfoJSONCarriesLastReport(t *testing.T) {
info := ResourceDefinitionInfo{
ResourceName: "extlic",
LastReport: 1800000000,
}
data, err := json.Marshal(info)
if err != nil {
t.Fatalf("json.Marshal: %v", err)
}
var m map[string]interface{}
if err := json.Unmarshal(data, &m); err != nil {
t.Fatalf("json.Unmarshal: %v", err)
}
v, ok := m["last_report"]
if !ok {
t.Fatalf("serialized body %s has no last_report key", data)
}
if v.(float64) != 1800000000 {
t.Errorf("last_report = %v, want 1800000000", v)
}
}

View file

@ -3,6 +3,7 @@ package output
import ( import (
"fmt" "fmt"
"strings" "strings"
"time"
"xcecli/models" "xcecli/models"
) )
@ -143,6 +144,8 @@ func (p *ResourcesPagePrinter) initColumnWidthsForDef(resourcesDef *models.Resou
width = len(resourceDef.ResourceType) width = len(resourceDef.ResourceType)
case models.HEADER_RESOURCE_DEF_DESC: case models.HEADER_RESOURCE_DEF_DESC:
width = len(resourceDef.Description) width = len(resourceDef.Description)
case models.HEADER_RESOURCE_DEF_LAST_REPORT:
width = len(formatJobTime(time.Unix(resourceDef.LastReport, 0)))
} }
if !p.wideFormat && width > 15 { if !p.wideFormat && width > 15 {
@ -270,6 +273,8 @@ func (p *ResourcesPagePrinter) formatRowsForDef(resourcesDef *models.ResourceDef
row = appendOrDefault(row, FormatStringField(builtinValue, width, truncatFlag, true), "-") row = appendOrDefault(row, FormatStringField(builtinValue, width, truncatFlag, true), "-")
case models.HEADER_RESOURCE_DEF_INTERVAL: case models.HEADER_RESOURCE_DEF_INTERVAL:
row = appendOrDefault(row, fmt.Sprintf("%"+align+"*d", width, resourceDef.Interval), "0") row = appendOrDefault(row, fmt.Sprintf("%"+align+"*d", width, resourceDef.Interval), "0")
case models.HEADER_RESOURCE_DEF_LAST_REPORT:
row = appendOrDefault(row, FormatStringField(fmtUnixTime(resourceDef.LastReport), width, truncatFlag, true), "-")
} }
} }
rows = append(rows, row) rows = append(rows, row)
@ -354,6 +359,8 @@ func printResourcesDefVerboseOutput(resourceDef *models.ResourceDefResponse) {
val = releaseValue val = releaseValue
case models.HEADER_RESOURCE_DEF_INTERVAL: case models.HEADER_RESOURCE_DEF_INTERVAL:
val = fmt.Sprintf("%d", resourceDef.Interval) val = fmt.Sprintf("%d", resourceDef.Interval)
case models.HEADER_RESOURCE_DEF_LAST_REPORT:
val = fmtUnixTime(resourceDef.LastReport)
} }
widths1[i] = max(len(h), len(val)) widths1[i] = max(len(h), len(val))
row1 = append(row1, FormatStringField(val, widths1[i], false, true)) row1 = append(row1, FormatStringField(val, widths1[i], false, true))

View file

@ -7,17 +7,18 @@ import (
) )
const ( const (
HEADER_RESOURCE_NAME = "RESOURCE" HEADER_RESOURCE_NAME = "RESOURCE"
HEADER_RESOURCE_VALUE = "VALUE" HEADER_RESOURCE_VALUE = "VALUE"
HEADER_RESOURCE_LOC = "LOCATION" HEADER_RESOURCE_LOC = "LOCATION"
HEADER_RESOURCE_DEF_TYPE = "TYPE" HEADER_RESOURCE_DEF_TYPE = "TYPE"
HEADER_RESOURCE_DEF_ORDER = "ORDER" HEADER_RESOURCE_DEF_ORDER = "ORDER"
HEADER_RESOURCE_DEF_BUILTIN = "BUILTIN" HEADER_RESOURCE_DEF_BUILTIN = "BUILTIN"
HEADER_RESOURCE_DEF_CONSUMABLE = "CONSUMABLE" HEADER_RESOURCE_DEF_CONSUMABLE = "CONSUMABLE"
HEADER_RESOURCE_DEF_DYNAMIC = "DYNAMIC" HEADER_RESOURCE_DEF_DYNAMIC = "DYNAMIC"
HEADER_RESOURCE_DEF_RELEASE = "RELEASE" HEADER_RESOURCE_DEF_RELEASE = "RELEASE"
HEADER_RESOURCE_DEF_INTERVAL = "INTERVAL" HEADER_RESOURCE_DEF_INTERVAL = "INTERVAL"
HEADER_RESOURCE_DEF_DESC = "DESCRIPTION" HEADER_RESOURCE_DEF_LAST_REPORT = "LAST_UPDATED"
HEADER_RESOURCE_DEF_DESC = "DESCRIPTION"
) )
var ResourceDistFieldsMap = map[string]string{ var ResourceDistFieldsMap = map[string]string{
@ -28,21 +29,23 @@ var ResourceDistFieldsMap = map[string]string{
// ResourceDefFieldsMap maps resource definition field headers to API field names // ResourceDefFieldsMap maps resource definition field headers to API field names
var ResourceDefFieldsMap = map[string]string{ var ResourceDefFieldsMap = map[string]string{
HEADER_RESOURCE_NAME: "res_name", HEADER_RESOURCE_NAME: "res_name",
HEADER_RESOURCE_DEF_DESC: "description", HEADER_RESOURCE_DEF_DESC: "description",
HEADER_RESOURCE_DEF_TYPE: "type", HEADER_RESOURCE_DEF_TYPE: "type",
HEADER_RESOURCE_DEF_ORDER: "order", HEADER_RESOURCE_DEF_ORDER: "order",
HEADER_RESOURCE_DEF_BUILTIN: "builtin", HEADER_RESOURCE_DEF_BUILTIN: "builtin",
HEADER_RESOURCE_DEF_CONSUMABLE: "consumable", HEADER_RESOURCE_DEF_CONSUMABLE: "consumable",
HEADER_RESOURCE_DEF_DYNAMIC: "dynamic", HEADER_RESOURCE_DEF_DYNAMIC: "dynamic",
HEADER_RESOURCE_DEF_RELEASE: "release", HEADER_RESOURCE_DEF_RELEASE: "release",
HEADER_RESOURCE_DEF_INTERVAL: "interval", HEADER_RESOURCE_DEF_INTERVAL: "interval",
HEADER_RESOURCE_DEF_LAST_REPORT: "last_report",
} }
var ResourceDefDetailHeaders = []string{ var ResourceDefDetailHeaders = []string{
HEADER_RESOURCE_DEF_TYPE, HEADER_RESOURCE_DEF_TYPE,
HEADER_RESOURCE_DEF_ORDER, HEADER_RESOURCE_DEF_ORDER,
HEADER_RESOURCE_DEF_INTERVAL, HEADER_RESOURCE_DEF_INTERVAL,
HEADER_RESOURCE_DEF_LAST_REPORT,
HEADER_RESOURCE_DEF_BUILTIN, HEADER_RESOURCE_DEF_BUILTIN,
HEADER_RESOURCE_DEF_DYNAMIC, HEADER_RESOURCE_DEF_DYNAMIC,
HEADER_RESOURCE_DEF_RELEASE, HEADER_RESOURCE_DEF_RELEASE,
@ -177,6 +180,7 @@ type ResourceDef struct {
OrderType string `json:"-"` // populated from order_type int OrderType string `json:"-"` // populated from order_type int
Flag map[string]interface{} `json:"flag"` Flag map[string]interface{} `json:"flag"`
Interval int `json:"interval"` Interval int `json:"interval"`
LastReport int64 `json:"last_report"` // Unix seconds of the last external value report (0 = never)
} }
func (r *ResourceDef) UnmarshalJSON(data []byte) error { func (r *ResourceDef) UnmarshalJSON(data []byte) error {
@ -187,6 +191,7 @@ func (r *ResourceDef) UnmarshalJSON(data []byte) error {
OrderType string `json:"order_type"` OrderType string `json:"order_type"`
Flag map[string]interface{} `json:"flag"` Flag map[string]interface{} `json:"flag"`
Interval int `json:"interval"` Interval int `json:"interval"`
LastReport int64 `json:"last_report"`
} }
var a Alias var a Alias
if err := UnmarshalOrderMapRow(data, &a); err != nil { if err := UnmarshalOrderMapRow(data, &a); err != nil {
@ -198,5 +203,6 @@ func (r *ResourceDef) UnmarshalJSON(data []byte) error {
r.OrderType = a.OrderType r.OrderType = a.OrderType
r.Flag = a.Flag r.Flag = a.Flag
r.Interval = a.Interval r.Interval = a.Interval
r.LastReport = a.LastReport
return nil return nil
} }

View file

@ -1184,10 +1184,16 @@ int xce_core_push_events(xce_sched_core_t* core, xce_core_event_t* events,
if ((key_find(core->resmgr, res->name, (void**)&found) if ((key_find(core->resmgr, res->name, (void**)&found)
== 0) == 0)
&& (found != NULL)) { && (found != NULL)) {
found->valtype = res->valtype; found->valtype = res->valtype;
found->ordtype = res->ordtype; found->ordtype = res->ordtype;
found->flags = res->flags; found->flags = res->flags;
found->interval = res->interval; found->interval = res->interval;
/* external-license-resources Phase 2: the extres
* freshness stamp (controller stamps it at each
* accepted push); resobj.c's makeResource excludes
* last_report from definition comparison, so this
* is metadata, not an identity change. */
found->last_report = res->last_report;
} else { } else {
WARN("%s RESOURCES_PUSH: makeResource failed for " WARN("%s RESOURCES_PUSH: makeResource failed for "
"%s and lookup not found", "%s and lookup not found",

View file

@ -27,11 +27,14 @@ traceback.
from __future__ import annotations from __future__ import annotations
import json
import os import os
import time
from collections.abc import Callable from collections.abc import Callable
from datetime import datetime, timezone from datetime import datetime, timezone
from importlib.metadata import PackageNotFoundError from importlib.metadata import PackageNotFoundError
from importlib.metadata import version as _package_version from importlib.metadata import version as _package_version
from pathlib import Path
import click import click
from click.core import ParameterSource from click.core import ParameterSource
@ -43,6 +46,7 @@ from xcecommon.errors import (
SchedAdminError, SchedAdminError,
TooManyRedirectsError, TooManyRedirectsError,
XceAdminError, XceAdminError,
XceExtresStatusError,
) )
from xceadmin.build import build_cluster_config, dump_cluster_config, write_output from xceadmin.build import build_cluster_config, dump_cluster_config, write_output
from xceadmin.push import probe_config, push_config from xceadmin.push import probe_config, push_config
@ -372,6 +376,126 @@ def scheduler_dump(file: str) -> None:
click.echo(text, nl=False) # dump() already ends its own text with "\n" (5.20). click.echo(text, nl=False) # dump() already ends its own text with "\n" (5.20).
# ---------------------------------------------------------------------------
# extres status (external-license-resources Phase 2 item 4, design.md 3.5):
# read the status JSON xceesd writes on its own host and render it for the
# operator. A LOCAL-file command like scheduler dump -- no --controller, no
# wire: the daemon's status file is the observability surface until the
# PR-2 proxy formalizes a status channel (design.md 3.6; a temporary
# remote protocol would be redesigned out from under us by PR-2's
# provider-lease model). Run it on the daemon's host.
# ---------------------------------------------------------------------------
# Where the deployed esd.yaml's status_file default points. Matches
# esd_config.c's status_file default and the deploy docs; overridable with
# --status-file (the t1 test cluster uses a /tmp path instead).
DEFAULT_STATUS_FILE = "/var/run/xce/esd-status.json"
def _format_extres_status(payload: dict, now: int | None = None) -> str:
"""Renders one decoded esd status object as operator text.
``now`` pins the wall clock for deterministic tests (None = time.time()).
Rendering rules:
- daemon identity line: name + pid
- uptime and push counters on their own lines
- per-source rows: NAME value=<v|-> age=<n>s [-- STALE] or
"no value yet" when last_value is null.
"""
if now is None:
now = int(time.time())
lines = [
f"daemon: {payload.get('daemon', 'xceesd')} (pid {payload.get('pid', '?')})",
]
started_at = payload.get("started_at", 0)
if isinstance(started_at, int) and started_at > 0:
uptime = max(now - started_at, 0)
started_str = datetime.fromtimestamp(started_at, tz=timezone.utc).strftime("%Y-%m-%dT%H:%M:%S")
lines.append(f"started at: {started_str} (up {uptime}s)")
lines.append(
f"pushes: {payload.get('pushes_ok', 0)} ok, "
f"{payload.get('pushes_fail', 0)} failed"
)
sources = payload.get("sources", [])
if not sources:
lines.append("sources: (none configured)")
return "\n".join(lines)
lines.append("sources:")
for src in sources:
name = src.get("name", "?")
if src.get("last_value") is None:
lines.append(f" {name}: no value yet")
continue
last_ok = src.get("last_ok_time", 0)
age = max(now - last_ok, 0) if isinstance(last_ok, int) and last_ok > 0 else 0
stale = " -- STALE" if src.get("stale") else ""
lines.append(f" {name}: value={src.get('last_value')} age={age}s{stale}")
return "\n".join(lines)
def _read_extres_status(path: str) -> dict:
"""Reads and validates one esd status file. Raises
:class:`XceExtresStatusError` for every local failure mode (unreadable
path, unparseable JSON, JSON that is not an esd status object) so the
CLI layer never sees a raw OSError/JSONDecodeError.
"""
try:
raw = Path(path).read_text()
except OSError as exc:
raise XceExtresStatusError(
f"cannot read extres status file {path!r}: {exc}", path=path, reason=str(exc)
) from exc
try:
payload = json.loads(raw)
except json.JSONDecodeError as exc:
raise XceExtresStatusError(
f"extres status file {path!r} is not valid JSON: {exc}", path=path, reason=str(exc)
) from exc
if not isinstance(payload, dict) or "sources" not in payload or "daemon" not in payload:
raise XceExtresStatusError(
f"extres status file {path!r} is not an xceesd status object "
"(missing 'daemon' or 'sources')",
path=path,
reason="wrong schema",
)
return payload
@cli.group()
def extres() -> None:
"""Inspect the external-resources daemon (xceesd)."""
@extres.command("status")
@click.option(
"--status-file",
default=None,
help=f"Path of the xceesd status JSON (default: {DEFAULT_STATUS_FILE}).",
)
def extres_status(status_file: str | None) -> None:
"""Report xceesd's status from its local status file (run on the daemon's host)."""
# Resolved at call time, not decoration time, so tests (and wrappers)
# can re-point the default -- click freezes a non-None default= into
# the command's own default_value at decoration.
if status_file is None:
status_file = DEFAULT_STATUS_FILE
try:
payload = _read_extres_status(status_file)
click.echo(_format_extres_status(payload))
except XceAdminError as e:
raise click.ClickException(e.message) from e
except KeyboardInterrupt:
raise click.ClickException("cancelled") from None
def main() -> None: def main() -> None:
"""Console-script entry point (see pyproject.toml [project.scripts]).""" """Console-script entry point (see pyproject.toml [project.scripts])."""
cli() cli()

View file

@ -222,3 +222,23 @@ class SchedAdminError(XceAdminError):
self.file = file self.file = file
self.event_count = event_count self.event_count = event_count
self.started_at = started_at self.started_at = started_at
class XceExtresStatusError(XceAdminError):
"""The xceesd status file (``xceadmin extres status``) could not be
read or decoded (external-license-resources Phase 2 item 4, design.md
3.5; raised by ``xceadmin.cli._read_extres_status``).
Covers every local-file failure mode: the path doesn't exist / isn't
readable, the bytes aren't valid JSON, or the JSON parses but isn't an
esd status object (missing ``daemon``/``sources`` -- e.g. someone
pointed ``--status-file`` at some other JSON file). Same local-file
concern as :class:`ReplayFileError`/:class:`ClusterBinFileError`: no
network framing involved -- ``extres status`` reads the status JSON
xceesd writes on its own host.
"""
def __init__(self, message: str, path: str, reason: str) -> None:
super().__init__(message)
self.path = path
self.reason = reason

View file

@ -0,0 +1,275 @@
"""Tests for the ``extres`` command group (external-license-resources
Phase 2 item 4, design.md 3.5 "``xceadmin extres status``").
``extres status`` is a LOCAL-file command -- the inverse slice of
``scheduler dump``: where ``dump()`` proved xceadmin can be a local-file
decoder with no network at all, ``extres status`` reads the status JSON
``xceesd`` writes on its own host (``esd_status_write``,
``src/esd/src/esd_status.c``) and formats it for the operator. No wire,
no ``--controller``, no TLS -- the daemon's status file is deliberately
the observability surface until the PR-2 proxy formalizes a status
channel (design.md 3.6; a temporary remote protocol would be
redesigned out from under us by PR-2's provider-lease model).
Split into its own file (rather than appended to ``test_cli.py``, 1187
lines at the time of writing) to keep both files under the project's
800-line guideline -- the same split precedent as
``test_cli_scheduler_mock.py``.
"""
from __future__ import annotations
import json
from pathlib import Path
import pytest
from click.testing import CliRunner
from xceadmin import cli as cli_module
from xceadmin.cli import cli
# ---------------------------------------------------------------------------
# Fixtures: realistic status JSON in the exact shape esd_status_write()
# emits (field order aside -- we parse, we never substring-match).
# ---------------------------------------------------------------------------
_NOW = 1_800_000_000 # a fixed "now" for deterministic age rendering
@pytest.fixture
def status_file(tmp_path: Path) -> Path:
"""A status file exactly as esd_status_write() writes it for two
sources: one fresh, one stale (last_ok_time 3600s in the past of
_NOW against the daemon's 150s default limit).
"""
path = tmp_path / "esd-status.json"
payload = {
"daemon": "xceesd",
"pid": 12345,
"started_at": _NOW - 7200,
"generated_at": _NOW,
"pushes_ok": 142,
"pushes_fail": 3,
"sources": [
{
"name": "Verilog_XL",
"last_value": 2.0,
"last_ok_time": _NOW - 5,
"stale": False,
},
{
"name": "VCS",
"last_value": 7.0,
"last_ok_time": _NOW - 3600,
"stale": True,
},
],
}
path.write_text(json.dumps(payload))
return path
def _freeze_now(monkeypatch: pytest.MonkeyPatch) -> None:
"""Pin time.time() to _NOW so age rendering is deterministic."""
monkeypatch.setattr(cli_module.time, "time", lambda: _NOW)
# ---------------------------------------------------------------------------
# Formatting helper: pure functions tested directly (the _format_record_
# result precedent in test_cli.py).
# ---------------------------------------------------------------------------
def test_format_status_renders_daemon_identity_and_counters() -> None:
from xceadmin.cli import _format_extres_status
payload = json.loads(
(Path(__file__).parent / "data" / "esd-status-sample.json").read_text()
) if (Path(__file__).parent / "data" / "esd-status-sample.json").exists() else {
"daemon": "xceesd",
"pid": 12345,
"started_at": _NOW - 7200,
"generated_at": _NOW,
"pushes_ok": 142,
"pushes_fail": 3,
"sources": [],
}
text = _format_extres_status(payload, now=_NOW)
assert "xceesd" in text
assert "12345" in text
assert "142" in text
assert "3" in text
def test_format_status_marks_stale_source_with_age() -> None:
from xceadmin.cli import _format_extres_status
payload = {
"daemon": "xceesd",
"pid": 1,
"started_at": _NOW - 100,
"generated_at": _NOW,
"pushes_ok": 0,
"pushes_fail": 0,
"sources": [
{
"name": "VCS",
"last_value": 7.0,
"last_ok_time": _NOW - 3600,
"stale": True,
}
],
}
text = _format_extres_status(payload, now=_NOW)
assert "VCS" in text
assert "STALE" in text
assert "3600s" in text
def test_format_status_fresh_source_shows_age_only() -> None:
from xceadmin.cli import _format_extres_status
payload = {
"daemon": "xceesd",
"pid": 1,
"started_at": _NOW - 100,
"generated_at": _NOW,
"pushes_ok": 0,
"pushes_fail": 0,
"sources": [
{
"name": "Verilog_XL",
"last_value": 2.0,
"last_ok_time": _NOW - 5,
"stale": False,
}
],
}
text = _format_extres_status(payload, now=_NOW)
assert "Verilog_XL" in text
assert "STALE" not in text
assert "5s" in text
def test_format_status_source_with_no_value_yet() -> None:
from xceadmin.cli import _format_extres_status
payload = {
"daemon": "xceesd",
"pid": 1,
"started_at": _NOW - 100,
"generated_at": _NOW,
"pushes_ok": 0,
"pushes_fail": 0,
"sources": [
{
"name": "Verilog_XL",
"last_value": None,
"last_ok_time": 0,
"stale": False,
}
],
}
text = _format_extres_status(payload, now=_NOW)
assert "Verilog_XL" in text
assert "no value yet" in text
# ---------------------------------------------------------------------------
# CLI wiring: arg parsing, file reading, error translation.
# ---------------------------------------------------------------------------
def test_cli_help_shows_extres_command() -> None:
runner = CliRunner()
result = runner.invoke(cli, ["--help"])
assert result.exit_code == 0
assert "extres" in result.output
def test_extres_status_reads_local_file(status_file: Path, monkeypatch: pytest.MonkeyPatch) -> None:
_freeze_now(monkeypatch)
runner = CliRunner()
result = runner.invoke(cli, ["extres", "status", "--status-file", str(status_file)])
assert result.exit_code == 0, result.output
assert "xceesd" in result.output
assert "Verilog_XL" in result.output
assert "VCS" in result.output
def test_extres_status_defaults_to_etc_path(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""With no --status-file, the default path is the deployed default
(/var/run/xce/esd-status.json -- where esd.yaml's production
status_file default points); a missing default file is a clean
error, not a traceback.
"""
monkeypatch.setattr(
cli_module, "DEFAULT_STATUS_FILE", str(tmp_path / "nonexistent.json")
)
runner = CliRunner()
result = runner.invoke(cli, ["extres", "status"])
assert result.exit_code != 0
assert "nonexistent.json" in result.output
def test_extres_status_missing_file_clean_error(tmp_path: Path) -> None:
runner = CliRunner()
result = runner.invoke(
cli, ["extres", "status", "--status-file", str(tmp_path / "gone.json")]
)
assert result.exit_code != 0
assert "gone.json" in result.output
assert "Traceback" not in result.output
def test_extres_status_invalid_json_clean_error(tmp_path: Path) -> None:
bad = tmp_path / "bad.json"
bad.write_text("{not json")
runner = CliRunner()
result = runner.invoke(cli, ["extres", "status", "--status-file", str(bad)])
assert result.exit_code != 0
assert "Traceback" not in result.output
def test_extres_status_wrong_schema_clean_error(tmp_path: Path) -> None:
"""JSON that parses but is not an esd status object (e.g. someone
pointed --status-file at some other JSON) fails loud and clean.
"""
bad = tmp_path / "other.json"
bad.write_text(json.dumps({"hello": "world"}))
runner = CliRunner()
result = runner.invoke(cli, ["extres", "status", "--status-file", str(bad)])
assert result.exit_code != 0
assert "Traceback" not in result.output
def test_extres_status_no_controller_needed(monkeypatch: pytest.MonkeyPatch, status_file: Path) -> None:
"""The command must not touch the controller: XCE_CONTROLLER_PEERS
pointing at a dead endpoint changes nothing (the file is local).
"""
monkeypatch.setenv("XCE_CONTROLLER_PEERS", "127.0.0.1:1")
monkeypatch.setenv("XCEADMIN_CONTROLLER", "127.0.0.1:1")
runner = CliRunner()
result = runner.invoke(cli, ["extres", "status", "--status-file", str(status_file)])
assert result.exit_code == 0, result.output