Iterated design review until clean (GO): - Controller main() calls mylog_fini() before every early return, so log lines written by tlog's background thread survive the exit. - cluster_init_phase1() keeps its candidates_count > 0 guard around the new xce_peers_find_identity() call (standalone stays my_priority 0). - xceadmin adds the "(from $VAR)" source note only to network errors, not to local cluster-bin file errors (OSError with filename set). - Regression script: scratch --workdir/--certdir/--logfile per daemon, refuses to run if a local cluster is up (port 9990, worker socket), --sim case asserts the sim branch ran, 2.3 patches follow_redirects, 2.8 malformed-entry cases use a readable cluster-bin file, wrapper test expects PEERS_STR form. - Two existing scripts that start xce_scheduler without --peers will unset XCE_CONTROLLER_PEERS; --interactive wording corrected. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
47 KiB
Design Document
Overview
Purpose: This feature lets an operator set one environment variable, XCE_CONTROLLER_PEERS, once per session or deployment, instead of retyping --peers (controller, scheduler, worker), set masters (harness/xce_test) or --controller (xceadmin) on every invocation. It changes nothing about what any of these components do once their connection target is resolved — only where that target's default value comes from when the operator did not name one explicitly.
Users: Operators running local/interactive HPC cluster tools, and deploy/test scripts (e.g. HA soak setups) that currently either hardcode --peers/--controller into every generated command or accept the single-node/no-controller default they didn't actually want.
Impact: Five independently-built binaries each gain one additional, lowest-priority source for their existing connection-target resolution. Explicit --peers/set masters behavior, "omitted, no env var" behavior (apart from a log line's wording, 1.8), and xceadmin's multi-candidate failover order are unchanged; xceadmin's parsing of any --controller value, explicit or not, gains name= stripping and a clean malformed-entry error (2.2, 2.8). Two guards come with the new source: the controller refuses to start, early and with a message naming the variable, when an env-supplied list does not contain its own name (that list is Raft membership, not just a target list), and deploy-ha.sh's generated scripts/xceadmin wrapper exports the new variable so an ambient value cannot redirect it.
Goals
- One new environment variable, used only when no explicit value is given, across controller, scheduler and worker (via
--peers), harness/xce_test (viaset masters) and xceadmin (via--controller). - An explicit value (
--peers,set masters,--controller) always wins over the variable. Explicit--peers/set mastersbehave exactly as today; an explicit--controllergains onlyname=stripping and a clean malformed-entry error (2.2, 2.8). - Zero behavior change when neither an explicit value nor the variable is set (apart from the startup log line's wording, 1.8).
- Reuse xceadmin's existing multi-candidate failover (
follow_redirects_any, bug-1821, unmodified) and its existingXCEADMIN_CONTROLLERfallback;resolve_controllers()is extended only as 2.2/2.8 require.
Non-Goals
- apiserver (YAML-config-based) and xcecli's
--url(a different target) — untouched. - Any change to
src/tools/deploy-ha.sh's generated daemon scripts' behavior (thescripts/xceadminwrapper's added export, Requirement 4, is the one exception). - Any change to what "no explicit value and no env var" does today for any component (only the log wording changes, 1.8).
- A TSAN/new-test-infrastructure investment for
main()-level argument parsing where none exists today (see Testing Strategy).
Boundary Commitments
This Spec Owns
- A single shared C primitive (
xce_controller_peers_env(),libxce) that readsXCE_CONTROLLER_PEERS, treating an unset or empty value as absent. - The one-line integration of that primitive into controller, scheduler, and worker's existing
--peers-gatingif, and into harness's settings-table default computation at startup. - A second shared C primitive,
xce_peers_find_identity()(libxce), holding the one rule for "which candidate is me" (candidatenameif set, elsehostname, compared with the controller's name). The controller's two existing copies of that loop — themy_priorityassignment incluster_init_phase1()(src/controller/cluster.c~L1784-1791) and the listen-port lookup incluster_open_listener()(~L1890-1906) — are switched to call it, each keeping its own existing no-match handling, so the new startup check and both existing lookups can never disagree. - The controller's startup identity check for an env-supplied candidate list (1.6).
- One added line in
deploy-ha.sh's generatedscripts/xceadminwrapper:export XCE_CONTROLLER_PEERS="$PEERS_STR"(Requirement 4). - Extending xceadmin's existing
resolve_controllers()to strip an optionalname=prefix per candidate entry. - Adding
"XCE_CONTROLLER_PEERS"ahead of the existing"XCEADMIN_CONTROLLER"in each--controlleroption'senvvar=list. - Help-text and documentation updates naming the new variable (CLI
--peers/--controllerhelp text,build-deployment.md,xce-deploy-ha/xce-test-multihostskill docs). - A repeatable regression script (
src/tools/verify-controller-peers-env.sh) proving the above across all 5 components, intended for ongoing re-use, not a one-time implementation check.
Out of Boundary
- apiserver's YAML-based peer configuration.
- xcecli's
--urloption. deploy-ha.sh's generated daemon scripts' own invocations (they keep passing--peersexplicitly). Thescripts/xceadminwrapper's one added export (Requirement 4) is the onlydeploy-ha.shchange.- Changing what the controller does when an explicit
--peerslist lacks its name. Todaycluster_open_listener()refuses (ERRORin the log,myapp_run()returns before any network I/O), andmain()ignoresmyapp_run()'s result, so the process exits with status 0. That behavior is kept; see Revalidation Triggers. - Any change to
xce_parse_peers()'s orresolve_controllers()/follow_redirects_any()'s existing parsing/failover semantics beyondresolve_controllers()'s two additions:name=stripping (2.2) and a clean error for a malformed entry (2.8). Both apply to an explicit--controllervalue too. - Refactoring any of the four C
main()functions to become unit-testable in general — only the two new, genuinely independent primitives are unit-tested (see Testing Strategy).
Allowed Dependencies
- All four C targets already link
libxce(verified per-componentmeson.build) — the shared primitives may live there with no new build-graph edge. - xceadmin's existing
resolve_controllers()/follow_redirects_any()(discovery.py) and its existing--controllerclick options (cli.py) — reused and extended in place, not duplicated.
Project Invariant Check
Walked against xce-audit-invariants' controller and scheduler dimensions:
- Consensus/replication safety (controller) — applies. For the
controller,
--peersis the Raft membership, andmy_prioritydoubles asself_idx(ha.c~L633, ~L2964). If the controller's name matches no candidate,cluster_init_phase1()(cluster.c~L1783) falls back to the last index, which would give the node another peer'sself_idx. That is already contained today:cluster_open_listener()(~L1902) refuses the same no-match case inmyapp_run()step 3, before the listener, iomgr or any election starts, so no Raft traffic is sent under the wrong identity. An inherited value makes the case far more likely, though (a controller started without--myname, so its name is the hostname, or one meant to run standalone), and the existing refusal then blames "--peers", logs only to the log file, and exits with status 0. Guard (1.6): when the list came fromXCE_CONTROLLER_PEERSandxce_peers_find_identity()finds no match,main.cwrites the error to stderr naming the variable and the remedies and returns 1, beforemyapp_run(). Because all three lookups now share one function, the new check cannot pass where the listener check would refuse, or the reverse. A bare singlehost:portvalue follows the same rule: its key is the hostname, so a controller joins a one-node HA cluster only when its name is literally that hostname; otherwise it refuses, rather than silently changing from standalone to HA mode. - Fail-loud, never self-exit (controller) — not affected. The new exit
happens in
main()during argument validation, the same place and shape as the existing--peersparse error and even-peer-count rejection; no daemon thread, event loop or HA state exists yet. - Async I/O thread roles (controller) — not affected. All reads of the
environment happen in
main()before any thread starts; the twocluster.crefactors are pure function calls in existing pre-election startup code (cluster_init_phase1(),cluster_open_listener()), with no blocking added. - Driver/simulation state-equivalence (scheduler) — not affected.
--sim(with or without--interactive) returns before the--peersblock, so the variable can never reach replay mode;--interactivewithout--simis a live run and takes the normal--peerspath.
Revalidation Triggers
xce_peers_find_identity()is now the only "which candidate is me" rule for the controller (the 1.6 check,my_priority, listen port). Any future identity rule (e.g. matching on host:port) changes there, never in a re-added inline loop.- If the explicit-
--peersno-match case is ever moved to the same earlymain()check with a non-zero exit (a reasonable follow-up, out of scope here), drop the env-only condition on the 1.6 check instead of adding a second check. - Any change to
xce_parse_peers()'s accepted value format (e.g. a new candidate syntax) must be mirrored inresolve_controllers()'s parsing if the two are to stay consistent forXCE_CONTROLLER_PEERSvalues shared between C daemons and xceadmin. - Any future change to harness's
settings[]initialization order (e.g. rc-file loading moved earlier than today) must preserve "the env-var-derived default is computed before any explicitset masterscan run." - If
deploy-ha.sh's generated daemon scripts are ever updated to rely onXCE_CONTROLLER_PEERSinstead of an explicit--peers(explicitly deferred here), re-check them against the baseline in this design's Out of Boundary, and re-check the 1.6 identity rule for each generated controller script.
Architecture
Existing Architecture Analysis
Each of the five components already resolves its own connection target independently, with no shared code path today:
| Component | Mechanism | Where "omitted" is decided | Existing fallback today |
|---|---|---|---|
| controller | argp, OPT_PEERS → arguments.peers_str |
if (arguments.peers_str) (main.c ~L455) |
Single-node primary (bug-1457). With a list, own entry found by name twice in cluster.c (~L1784 my_priority, ~L1890 listen port); no match is refused in cluster_open_listener() before any network I/O, exit status 0 |
| scheduler | argp, 'P'/--peers → arguments.peers |
if (arguments.peers) (main.c ~L411); --sim returns before this point is reached |
"single-controller path" (logged) |
| worker | getopt_long, OPT_PEERS → peers_str |
if (peers_str) (main.c ~L470) |
Single-controller path via config.remote_host/config.remote_port (logged; same shape as controller/scheduler's own single-controller fallback, not an error path) |
| harness/xce_test | settings[] table, key "masters", no CLI flag at all |
get_setting()'s value ?: defval (test.c ~L991) |
defval = "localhost:9990" |
| xceadmin | click --controller option, envvar="XCEADMIN_CONTROLLER" |
click's own parameter resolution | _DEFAULT_CONTROLLER = "localhost:9991" |
All three C daemons that gate on a --peers argument already treat an invalid value as a hard startup error (xce_parse_peers() != 0 → message + return 1), never a silent fallback — this feature preserves that for both an invalid explicit flag and an invalid XCE_CONTROLLER_PEERS value.
Architecture Integration
- Selected pattern: two shared, stateless C primitives (an env reader used by four call sites, and an identity lookup used by three controller sites), plus one Python parsing extension consulted by click's own (unmodified) resolution order — no new service, process, or inter-component call is introduced.
- Domain/feature boundaries: each component keeps deciding its own resolved value; the new variable is only ever a source, never a coordinator.
- Existing patterns preserved:
xce_parse_peers()'sname=-prefix convention; xceadmin'sresolve_controllers/follow_redirects_anyfailover; each component's existing hard-error-on-invalid-value behavior. - New components rationale: two new C functions.
xce_controller_peers_env()is a bare env reader because harness's settings-table shape cannot reuse a "givencli_value, return resolved" signature the other three could share (seeresearch.md, Design Decisions).xce_peers_find_identity()exists so the controller's new startup check and its two existing lookups (my_priority, listen port) share one matching rule. - Steering compliance: no new build dependency; C standard/build system (Meson) and Python (click) usage unchanged.
Technology Stack
| Layer | Choice / Version | Role in Feature | Notes |
|---|---|---|---|
| C CLI / libxce | gnu17, existing libxce static lib |
New xce_controller_peers_env() (4 call sites) and xce_peers_find_identity() (controller main.c + two cluster.c sites) |
No new dependency |
| Python CLI | click (already a xceadmin dependency) |
Native multi-envvar list resolution (no new click usage pattern) |
Confirmed via direct read of the installed click version's resolve_envvar_value() |
File Structure Plan
One new file (the regression script below); every other change is to an existing file — no new directories.
New Files
src/tools/verify-controller-peers-env.sh— repeatable regression script (see Testing Strategy → Scripted Regression Verification) driving the real built binaries of controller, scheduler, worker and harness/xce_test, plusdeploy-ha.sh's generated xceadmin wrapper, asserting on startup log/settings-dump/printed-environment output only (no live cluster required).
Modified Files
src/include/xce/cluster.h— declareconst char *xce_controller_peers_env(void);andint xce_peers_find_identity(const xce_cluster_config_t *cfg, const char *name);alongside the existingxce_parse_peers()declaration.src/lib/cluster.c— implementxce_controller_peers_env(): readgetenv("XCE_CONTROLLER_PEERS"), return it if non-empty, elseNULL. Implementxce_peers_find_identity()with the matching rule moved verbatim fromsrc/controller/cluster.c(identical at ~L1784-1791 and ~L1890-1900).src/lib/testsuite/test_cluster.c— new unit tests forxce_controller_peers_env()(unset, empty, non-empty) andxce_peers_find_identity()(named match, bare-hostname match, named entry not matched by its hostname, no match → -1, first of duplicate names wins, NULL name → -1, empty name matching an unnamed empty-hostname entry exactly as today).src/controller/main.c— at the existingif (arguments.peers_str)site (~L455), resolvearguments.peers_stragainstxce_controller_peers_env()before the check, remembering in a localpeers_from_envwhether the env var supplied it; on a parse or even-count failure, name the source (flag vs. environment variable) in the existing error message; after parsing, whenpeers_from_envis true andxce_peers_find_identity(&myconf.peers_cfg, arguments.myname) < 0, print the 1.6 error andreturn 1; extend the "No --peers flag"INFO(~L486) per 1.8; callmylog_fini()before every earlyreturnaftermylog_init()(~L393) — theget_myname()failure (~L403), themylock_init()failure (~L452), the--peersparse and even-count errors, the new 1.6 check, and the cert pre-flight (~L489-496); it is a no-op beforemylog_init()and safe to call twice — because tlog writes the log file from a background thread with noatexitflush, so today those returns can lose already-logged lines (the scheduler already drains explicitly for the same reason); addXCE_CONTROLLER_PEERSand the "unset it to run standalone" note (1.7) to the--peersoption's help text (~L85-87).src/controller/cluster.c— replace both inline identity loops withxce_peers_find_identity(pcfg, c->myconf->name): incluster_init_phase1()(~L1784-1791), keep the existingif (pcfg->candidates_count > 0)guard (~L1782; standalone mode leavesmy_priority = 0) and replace only the loop inside it withmy_priority = idx >= 0 ? idx : pcfg->candidates_count - 1;; incluster_open_listener()(~L1890-1906),matched = idx >= 0; my_port = matched ? pcfg->candidates[idx].port : 0;followed by the existing refusal and default-port code. Same result for every input.src/tools/deploy-ha.sh— in the generatedscripts/xceadminwrapper heredoc (~L689-694), addexport XCE_CONTROLLER_PEERS="$PEERS_STR"next to the existingXCEADMIN_CONTROLLERexport, and extend the comment above it to say why (Requirement 4).src/scheduler/src/main.c— identical shape at the existingif (arguments.peers)site (~L411), without the identity check; name the source in both the parse-error and thexce_cluster_config_validate()failure messages (~L412-424, 1.4); "No --peers flag"INFO(~L435) per 1.8; help text update with 1.7's note (~L88). No change anywhere in the--simpath, which already returns before this point.src/worker/src/main.c— identical shape at the existingif (peers_str)site (~L470), without the identity check; name the source in both the parse-error and thexce_cluster_config_validate()failure messages (~L472-485, 1.4); "No --peers flag" log line (~L501) per 1.8; update the-h/usageprintf(~L443-446) to mentionXCE_CONTROLLER_PEERS, 1.7's note, and thatsudodrops it unless preserved.src/harness/test.c— at startup, before the REPL loop and before rc-file loading, compute the"masters"entry'sdefvalfromxce_controller_peers_env()if set, else leave it at today's literal"localhost:9990"; extend theUSAGE/set-command help text (~L403-415) to mention the new variable and 1.7's note.src/xceadmin/src/xceadmin/discovery.py— extendresolve_controllers()'s per-entry parsing to strip an optionalname=prefix (mirrorsxce_parse_peers()) before its existingrsplit(":", 1); turn today's uncaughtValueErrorfromint(port_str)(~L78) into a newControllerValueErrornaming the bad entry (2.8).src/xceadmin/src/xcecommon/errors.py— addControllerValueError(XceAdminError)next to the existing controller-reachability errors (ConnectionTimeoutError,TooManyRedirectsError,NoPrimaryError).src/xceadmin/src/xceadmin/cli.py— change_CONTROLLER_ENV_VAR(~L109), which all 4--controlleroptions use, to the list["XCE_CONTROLLER_PEERS", "XCEADMIN_CONTROLLER"]; rewrite each option's help string, which today interpolates_CONTROLLER_ENV_VARwith an f-string and would print a raw Python list once it becomes one, and the precedence comment above it (~L102-108). Add a helper_controller_source_note()that returns" (from $NAME)"whenclick.get_current_context().get_parameter_source("controller")isParameterSource.ENVIRONMENT(NAME = the first non-empty variable in the list, the same rule click applies) and""otherwise; append it to the two existingcannot connect to {controller}messages (~L172, ~L265) only when theOSErroris not a local file error (e.filename is None: socket errors carry no filename, whileread_bytes()of a missing or unreadable cluster-bin file inpush_config()/probe_config()(push.py~L89, ~L143) raises aFileNotFoundError/PermissionErrorwithfilenameset, which reaches the sameexcept OSErrorbranch today and must not be blamed on the variable), and, in the existingexcept XceAdminErrorhandlers of the--controller-accepting commands, to the message when the error is one of_CONTROLLER_ERRORS = (ControllerValueError, ConnectionTimeoutError, TooManyRedirectsError, NoPrimaryError)(2.8). Errors unrelated to the controller value (e.g. a missing cluster-bin file, orClusterBinFileErrorfromconfig build) keep their text unchanged.src/tools/test-version-startup-consistency.sh,src/tools/test-dirty-fingerprint-startup-log-unchanged.sh— addunset XCE_CONTROLLER_PEERSnear the top, so their scheduler run keeps taking the no-peers path they were written for (see Testing Strategy, ambient-variable exposure).src/xceadmin/tests/test_discovery.py— new unit tests forresolve_controllers()'sname=-stripping and itsControllerValueErroron a non-numeric port.src/xceadmin/tests/test_cli.py— new unit tests for the new env-var precedence (mirrors the existingtest_controller_option_falls_back_to_xceadmin_controller_env_var/test_controller_flag_overrides_xceadmin_controller_env_vartests) and for 2.8's messages..kiro/steering/build-deployment.md— documentXCE_CONTROLLER_PEERS: components, value format, precedence, the "unset it to run standalone" opt-out, the controller's own-name rule (1.6), and thatsudo(defaultenv_reset) and systemd services do not inherit it (sudo --preserve-env=XCE_CONTROLLER_PEERS, orEnvironment=in the unit)..claude/skills/xce-deploy-ha/SKILL.md— note the variable's availability, that the generated daemon scripts still pass--peers, and thatscripts/xceadminnow also exportsXCE_CONTROLLER_PEERS(update the existing ~L330 description and the ~L518 troubleshooting row's "check$TOP/scripts/xceadmin" hint)..claude/skills/xce-test-multihost/SKILL.md— same note, at its three existing--peers-mentioning locations (single-node deploy step,xcecd_virtualflag-table note, Prerequisites' generated-script list).
System Flows
flowchart TD
Start[Resolution point reached] --> HasFlag{Explicit flag or\nset command given?}
HasFlag -- yes --> UseFlag[Use that value\nenv var not used as list]
HasFlag -- no --> HasNewEnv{XCE_CONTROLLER_PEERS\nnon-empty?}
HasNewEnv -- yes --> IsCtrl{Component is controller?}
IsCtrl -- no --> UseNewEnv[Use it\nxceadmin strips name prefix]
IsCtrl -- yes --> SelfListed{Own name matches\na candidate?}
SelfListed -- yes --> UseNewEnv
SelfListed -- no --> Refuse[Refuse to start\nname source and remedies]
HasNewEnv -- no --> IsAdmin{Component is xceadmin?}
IsAdmin -- yes --> HasLegacyEnv{XCEADMIN_CONTROLLER\nnon-empty?}
IsAdmin -- no --> UseTodayDefault[Today's unchanged default\nstandalone / built-in literal]
HasLegacyEnv -- yes --> UseLegacyEnv[Use it, unchanged behavior]
HasLegacyEnv -- no --> UseTodayDefault
All three branches that resolve to a real candidate list (UseFlag, UseNewEnv, UseLegacyEnv) still pass through each component's existing validation (xce_parse_peers() and the controller's odd-count rule, unchanged; resolve_controllers(), extended per 2.2/2.8); an invalid value is a hard error either way, distinguished by source only in the error message text (1.4, 2.8).
This resolution point is reached exactly once, at process startup, for controller/scheduler/worker/xceadmin. For harness/xce_test it is reached on every connection attempt (enter_ctx()), since set masters ... can run at any point in an interactive/scripted session — the diagram represents one such evaluation, not a startup-only decision, for that component.
Requirements Traceability
| Requirement | Summary | Components | Interfaces | Flows |
|---|---|---|---|---|
| 1.1 | Explicit candidate list wins, env var not used as candidate list | controller/scheduler/worker main.c; harness test.c (explicit set masters, interactive or rc-file) |
existing if (peers) gate; get_setting() value-before-defval |
Flowchart: HasFlag = yes |
| 1.2 | Env var used when no explicit list (harness: at each connection attempt) | controller/scheduler/worker main.c, harness test.c | xce_controller_peers_env(); harness masters defval |
Flowchart: HasNewEnv → UseNewEnv |
| 1.3 | Unset/empty env var ⇒ unchanged today's behavior | all 4 | same call sites | Flowchart: UseTodayDefault |
| 1.4 | Parse/validation error (incl. controller even count) names the source | controller/scheduler/worker main.c, harness test.c | existing error fprintfs (parse, controller even-count)/enter_ctx message |
n/a |
| 1.5 | --peers (harness: set) help text documents the variable |
controller/scheduler/worker main.c, harness test.c | argp/getopt/settings help strings | n/a |
| 1.6 | Controller refuses an env-supplied list without its own name | controller main.c | xce_peers_find_identity() |
Flowchart: SelfListed = no |
| 1.7 | Opt-out is "unset the variable"; help says so | controller/scheduler/worker main.c, harness test.c | help strings; xce_controller_peers_env() empty-as-unset |
Flowchart: HasNewEnv = no |
| 1.8 | "No --peers" log line says the variable was unset or empty | controller/scheduler/worker main.c | existing INFO/log line text |
Flowchart: UseTodayDefault |
| 2.1 | Derive controller list from env var | xceadmin cli.py, discovery.py | click envvar= list |
Flowchart: UseNewEnv |
| 2.2 | Strip name= prefix, any source incl. explicit --controller |
xceadmin discovery.py | resolve_controllers() |
n/a |
| 2.3 | Try each candidate in order | xceadmin discovery.py | resolve_controllers/follow_redirects_any (unmodified) |
n/a |
| 2.4 | Explicit --controller wins |
xceadmin cli.py | click parameter resolution (unmodified) | Flowchart: HasFlag = yes |
| 2.5 | Legacy XCEADMIN_CONTROLLER fallback unchanged |
xceadmin cli.py | click envvar= list order |
Flowchart: HasLegacyEnv |
| 2.6 | Both unset ⇒ hardcoded default unchanged | xceadmin cli.py | _DEFAULT_CONTROLLER (unmodified) |
Flowchart: UseTodayDefault |
| 2.7 | --controller help text documents the variable |
xceadmin cli.py | click option help strings | n/a |
| 2.8 | Clean error for unreachable/malformed; names the env var source | xceadmin cli.py, discovery.py | ControllerValueError; _controller_source_note() |
n/a |
| 3.1 | Steering doc describes the variable: components, format, precedence, opt-out (1.7), own-name rule (1.6), sudo/systemd not inheriting it |
build-deployment.md |
n/a | n/a |
| 3.2 | HA deploy guidance notes availability and the wrapper export | xce-deploy-ha/SKILL.md |
n/a | n/a |
| 3.3 | Multi-host guidance notes availability | xce-test-multihost/SKILL.md |
n/a | n/a |
| 4.1 | Wrapper exports the deployment's own list | deploy-ha.sh wrapper heredoc |
export XCE_CONTROLLER_PEERS="$PEERS_STR" |
n/a |
| 4.2 | Wrapper keeps XCEADMIN_CONTROLLER |
deploy-ha.sh wrapper heredoc |
existing export, unchanged | n/a |
| 4.3 | Explicit --controller still wins in the wrapper |
xceadmin cli.py | click parameter resolution (unmodified) | Flowchart: HasFlag = yes |
Components and Interfaces
| Component | Domain/Layer | Intent | Req Coverage | Key Dependencies | Contracts |
|---|---|---|---|---|---|
xce_controller_peers_env() |
libxce / C | Read XCE_CONTROLLER_PEERS, empty-as-unset |
1.2, 1.3, 1.7 | none (P0) | Service |
xce_peers_find_identity() |
libxce / C | One rule for "which candidate is me" | 1.6 | none (P0) | Service |
controller/scheduler/worker --peers call sites |
C daemon CLI | Combine flag + env var, preserve existing validation/help; controller adds the 1.6 check | 1.1-1.8 | xce_controller_peers_env (P0), xce_peers_find_identity (P0, controller only) |
— |
harness masters setting default |
C CLI (settings table) | Compute defval from env var at startup |
1.1-1.5, 1.7 | xce_controller_peers_env (P0) |
State |
resolve_controllers() extension |
xceadmin / Python | Strip name= prefix per entry; clean error on a malformed entry |
2.1, 2.2, 2.8 | none (P0) | Service |
xceadmin --controller options |
xceadmin / Python (click) | Add XCE_CONTROLLER_PEERS ahead of legacy envvar; name the env source in errors |
2.1, 2.4-2.8 | resolve_controllers (P0), click (P0, external) |
— |
scripts/xceadmin wrapper export |
deploy-ha.sh |
Pin the wrapper to its own deployment | 4.1-4.3 | none | — |
| Documentation updates | Docs | Describe/announce the variable | 3.1-3.3 | none | — |
Only the three components introducing genuinely new logic — xce_controller_peers_env(), xce_peers_find_identity() and the resolve_controllers() extension — warrant a full contract block; the remaining rows are thin call-site wiring around existing, unchanged behavior.
libxce
xce_controller_peers_env
| Field | Detail |
|---|---|
| Intent | Sole read point for XCE_CONTROLLER_PEERS, normalizing empty-string to absent |
| Requirements | 1.2, 1.3, 1.7 |
Responsibilities & Constraints
- Pure function: no side effects, no caching (the environment can only change between process invocations for these components, so no staleness concern).
- Treats an empty string identically to an unset variable, matching the same convention
resolve_controllers()'s per-entry blank-skip already uses.
Dependencies
- Inbound: controller/scheduler/worker
main.c--peerscall sites (P0), harnesstest.cstartupmasters-default computation (P0). - Outbound: none.
Contracts: Service [x] / API [ ] / Event [ ] / Batch [ ] / State [ ]
Service Interface
/* src/include/xce/cluster.h */
const char *xce_controller_peers_env(void);
- Preconditions: none.
- Postconditions: returns a pointer into the process environment (valid for the process lifetime) or
NULL. - Invariants: never returns a non-NULL empty string.
xce_peers_find_identity
| Field | Detail |
|---|---|
| Intent | Find the controller's own candidate in a parsed peers list |
| Requirements | 1.6 |
Responsibilities & Constraints
- Pure function over an already-parsed
xce_cluster_config_t; no environment or global access. - Matching rule, moved unchanged from
src/controller/cluster.c(~L1784-1791 and ~L1890-1900, identical today): a candidate's key is itsnamewhen non-empty, else itshostname; the first candidate whose key equalsname(compared withstrncmp(key, name, XCE_MAXNAME_LEN - 1)) wins. - Does not apply any default: "no match → last index" stays at the
my_prioritycall site, "no match → refuse" atcluster_open_listener(), and the 1.6 check treats no match as an error.
Dependencies
- Inbound: controller
main.cstartup check (P0), controllercluster.cmy_priorityassignment and listen-port lookup (P0). - Outbound: none.
Contracts: Service [x] / API [ ] / Event [ ] / Batch [ ] / State [ ]
Service Interface
/* src/include/xce/cluster.h */
int xce_peers_find_identity(const xce_cluster_config_t *cfg, const char *name);
- Preconditions:
cfgnon-NULL. - Postconditions: returns the index (0..
candidates_count-1) of the first matching candidate, or -1 whennameis NULL or nothing matches. An emptynameis compared like any other (today's loops do the same: it matches an unnamed entry with an empty hostname, e.g. from:5), so the invariant below holds for every input. - Invariants: for every input with
candidates_count > 0,idx >= 0 ? idx : candidates_count - 1equals whatcluster.ccomputes formy_prioritytoday (with no candidates, the call site's existing guard keepsmy_priority = 0, and the helper returns -1); and for every input,idx >= 0equals the listener'smatchedtoday.
xceadmin
resolve_controllers (extended)
| Field | Detail |
|---|---|
| Intent | Parse a --controller-shaped value into an ordered candidate list, now tolerant of name= prefixes |
| Requirements | 2.1, 2.2, 2.8 |
Responsibilities & Constraints
- Existing behavior (whitespace stripping, blank-entry skipping, bare-host default port) is unchanged.
- New: for each comma-separated entry, if it contains
=, discard everything up to and including the first=before applying the existingrsplit(":", 1). - New: a port that is not an integer raises
ControllerValueError(anXceAdminErrorsubclass) naming the entry, instead of today's uncaughtValueError. - Both changes apply to every caller, an explicit
--controllervalue included.
Dependencies
- Inbound: xceadmin
--controlleroptions (P0), any future caller expectingname=host:porttolerance. - Outbound: none new.
Contracts: Service [x] / API [ ] / Event [ ] / Batch [ ] / State [ ]
Service Interface
def resolve_controllers(controller: str) -> list[tuple[str, int]]: ...
- Preconditions:
controlleris a comma-separated string of[name=]host[:port]entries. - Postconditions: returns an ordered list of
(host, port)tuples,name=prefixes (if any) stripped; raisesControllerValueErroron a malformed entry. - Invariants: a bare
host:portentry (today's only supported shape) parses identically to before.
Implementation Notes
- Integration:
follow_redirects_any()is untouched — it only ever consumesresolve_controllers()'s output. - Validation: existing
test_discovery.pycoverage for bare-host/list parsing must stay green; new tests addname=-prefixed and mixed-prefix cases. - Risks: none identified (see
research.mdRisks & Mitigations for the=-in-hostname non-issue).
Error Handling
Error Strategy
Every existing hard-error-on-invalid-value behavior is preserved exactly, with the value's source named in the message text (1.4). The one new error is the controller's own-name check for an env-supplied list (1.6).
Error Categories and Responses
- Invalid candidate list, from an explicit
--peersvalue: unchanged exit behavior; message names--peersas the source (most already do; scheduler's and worker'sxce_cluster_config_validate()messages gain it). - Invalid candidate list, from an explicit
set masters: unchanged behavior;enter_ctx()'s message names the source as described in the next bullet. - Invalid or unreachable value, from an explicit
--controller: today's unreachable messages unchanged; a malformed entry now gives the 2.8 clean error instead of a traceback. No source note is added, since the operator typed the value. - Invalid candidate list, from
XCE_CONTROLLER_PEERS: same parse or validation failure (including the controller's even-count rejection), same exit behavior, message additionally names the environment variable as the source (controller/scheduler/worker: adjust the existingfprintf; harness:enter_ctx()'s existing "invalid 'masters' setting" message, extended to say "default (from XCE_CONTROLLER_PEERS)" when the"masters"entry'ssettings[i].value == NULL(noset mastersran, reusingget_setting()'s own value-before-defval precedence) andxce_controller_peers_env() != NULL; "explicitset masters" whenvalue != NULL; and today's wording otherwise (the built-inlocalhost:9990literal, which cannot fail to parse today, is then never blamed on the variable). - Controller name not in an env-supplied list (1.6):
main.cprints, to stderr, e.g.xce_controller: XCE_CONTROLLER_PEERS (a=h1:1,b=h2:2) has no candidate named 'node7'; pass --myname <one of: a, b> or unset XCE_CONTROLLER_PEERS to run standalone, thenreturn 1before the cert pre-flight andmyapp_run(). This replaces, for the env-var case only, the latercluster_open_listener()refusal, which blames "--peers", writes only to the log file, and exits with status 0. Candidates without aname=are listed by hostname. - xceadmin, unreachable candidates or a malformed entry (2.8):
click.ClickException(exit 1) with today's text (cannot connect to {controller}: ...forOSError, or thee.messageofConnectionTimeoutError/TooManyRedirectsError/NoPrimaryError), orinvalid --controller entry '...'forControllerValueError, followed by(from $XCE_CONTROLLER_PEERS)or(from $XCEADMIN_CONTROLLER)when the value came from the environment. Today a malformed port is an uncaughtValueErrortraceback.
Monitoring
No new logging/metrics infrastructure; each component's existing startup log line (e.g. scheduler's INFO("HA cluster config: ...") / INFO("No --peers flag; ...")) is extended only to remain accurate about which source supplied the value it's reporting. The three "No --peers flag" lines (controller main.c ~L486, scheduler ~L435, worker ~L501) become "No --peers flag and XCE_CONTROLLER_PEERS unset or empty; ..." (1.8). This is the operator's only clue when sudo's default env_reset or a systemd unit drops the variable — the main risk for the production worker, which check_user() requires to run as root (deploy-ha.sh ~L470 starts it with sudo).
Testing Strategy
Unit Tests
xce_controller_peers_env()(src/lib/testsuite/test_cluster.c): unset →NULL; empty string →NULL; non-empty → returned unchanged. (1.2, 1.3, 1.7)xce_peers_find_identity()(src/lib/testsuite/test_cluster.c): the cases listed in File Structure Plan, each written first as a failing test. The existing controller tests that go throughmy_priorityandcluster_open_listener()must stay green aftercluster.cswitches to the helper. (1.6)resolve_controllers()extension (src/xceadmin/tests/test_discovery.py): singlename=host:port; mixed bare/named multi-candidate list; existing bare-host cases still pass unchanged. (2.2)- xceadmin
--controlleroption precedence (src/xceadmin/tests/test_cli.py):XCE_CONTROLLER_PEERSset alone; bothXCE_CONTROLLER_PEERSandXCEADMIN_CONTROLLERset (new wins); explicit--controlleroverrides both; neither set (existing default). (2.1, 2.4-2.6) For 2.3: withXCE_CONTROLLER_PEERS="a=127.0.0.1:1,b=127.0.0.1:2,127.0.0.1:3"and no--controller, patchfollow_redirects(the per-candidate call insidefollow_redirects_any) so the first candidate raises a connection error and the second returns a canned reply, and assert the candidates arrive in env order withname=stripped and that the second one is tried next. (2.3) For 2.8: an unreachable value from each ofXCE_CONTROLLER_PEERS,XCEADMIN_CONTROLLERand an explicit--controller(message names the variable in the first two only), and a non-numeric port giving a clean error rather than a traceback — run with a readable cluster-bin file too, sinceconfig pushreads the file before resolving controllers: once as an explicit--controller a=h:x(message quotes the entry, no source note) and once asXCE_CONTROLLER_PEERS=a=h:xwith no--controller(message quotes the entry and ends(from $XCE_CONTROLLER_PEERS)). Run the unreachable cases throughconfig pushwith a real, readable cluster-bin file (push_config()reads it before resolving controllers, so a missing file raisesFileNotFoundErrorfirst — add one case asserting that error gets no source note even withXCE_CONTROLLER_PEERSset), or patch out the retry sleep, since thescheduler recordcommands retryNoPrimaryError8 times with a 2 s pause (scheduler.py~L131-132). All existingtest_cli.pycases that assertXCEADMIN_CONTROLLER-only or no-env-var behavior (e.g.test_controller_option_falls_back_to_xceadmin_controller_env_var) must explicitlymonkeypatch.delenv("XCE_CONTROLLER_PEERS", raising=False), so the new variable's introduction cannot make them order-dependent on ambient environment state.
Scripted Regression Verification
None of controller/scheduler/worker/harness's main()-level argument or
settings-table resolution has existing unit-test coverage today (confirmed
via source search; test_cluster_parse.c tests a standalone duplicated
reference parser, not live main() wiring) — refactoring any of the four
into a unit-testable shape is out of this feature's boundary (see
Non-Goals). Rather than one-time manual checking, this is verified by a new
repeatable script, src/tools/verify-controller-peers-env.sh, following
xce-test-multihost's established pattern of driving real built binaries
and asserting on their output — but deliberately lightweight: it asserts on
each component's startup log/settings-dump line, not on a live,
reachable cluster, since resolving the candidate list happens, and is
observable, entirely before any connection attempt succeeds or fails.
The worker cases use xcecd_virtual, since the production xcecd
requires root (check_user()), and grep the worker's captured stdout,
not xcecd-<name>.log: its "HA cluster config" / "No --peers flag" lines
(worker/src/main.c ~L493-503) are logged before its log file is set up
(~L529-544), so the logging library still writes them to stdout. The
controller and scheduler set up their log files earlier (controller
main.c ~L393, scheduler ~L332), so their cases grep the log file. Controller cases always use an odd
candidate count and pass --myname naming a candidate unless the case
is about the 1.6 check, so the even-count rule and 1.6 do not mask what
the case is testing.
Every daemon run is isolated in a fresh scratch directory: the controller
always gets --workdir, --certdir and --logfile there (otherwise it
defaults to /tmp/xce and /tmp/xce_controller.log, src/include/config.h:22,
main.c:312, and could touch a local cluster's state), the scheduler
--logfile, and the worker -l. With an empty certificate directory the
controller exits at the certificate pre-flight (main.c ~L489), right
after logging the line under test (~L476/~L486) and after the 1.6 check,
so it never reaches myapp_run(); the mylog_fini() added before that
return (File Structure Plan, main.c) is what guarantees the line is in
the log file when the script reads it. Scheduler and worker runs are killed
after a short timeout once the asserted line has appeared. Two things a
scratch directory cannot isolate: with no peers list the scheduler and
worker still try their default controller (localhost:9990), and the
worker always uses the hardcoded /var/run/.xcecd.sock. The script
therefore checks first that nothing listens on port 9990 and that the
socket does not exist, and fails with a message (rather than running)
when a local cluster is up.
- controller/scheduler/worker: with
XCE_CONTROLLER_PEERSset (e.g.a=localhost:1,b=localhost:2,c=localhost:3; controller also--myname a) and no--peers, start briefly and grep the log (worker: stdout) for the line each already logs today for an explicit--peersvalue — controllerHA mode: N controller candidates, identity=...(main.c~L476), scheduler and workerHA cluster config: N candidate(s), ...(scheduler ~L431, worker ~L493) — assertingNmatches the env var's content — no reachable target required. (1.2) - Same three, with both
XCE_CONTROLLER_PEERSset to one value (3 candidates) and--peersset to a different one (1 candidate, which the controller's--mynamenames); assert the log reflects the--peersvalue, not the env var's (worker: stdout). (1.1) - Same three, with neither set; assert the "No --peers flag" (or
worker's "single-controller path", on stdout) log line now also says
XCE_CONTROLLER_PEERSwas unset or empty, and that behavior is otherwise unchanged. (1.3, 1.8) - scheduler with
--sim <nonexistent file>andXCE_CONTROLLER_PEERSset: assert stderr showsxce_scheduler: --sim startup failed, which only the--simbranch prints (proving that branch ran, so the case cannot pass by failing earlier), and that the process never logs an "HA cluster config"/"No --peers flag" line at all (the--simpath returns before that code is reached — this is a by-construction guarantee already, not new logic; the script asserts it stays true). (Non-Goals cross-check) - harness/
xce_test(always with--norc, so a developer's own rc file cannot change the result): run non-interactively with a script containing onlyset(no arguments) —cmd_set's existing argc==1 branch prints every setting's current value/default with no connection attempt — first withXCE_CONTROLLER_PEERSset and no priorset masters, asserting the printedmastersdefault reflects the env var; then with an explicitset masters ...line before the bareset, asserting the printed value (not default) reflects the explicit override. (1.1, 1.2) - For each of controller, scheduler and worker, trigger a parse error
once with an invalid explicit
--peersand once with an invalidXCE_CONTROLLER_PEERSand no--peers; assert on stderr (all three write these errors withfprintf(stderr, ...)) that the two messages name different sources. For the controller, repeat with a 2-candidate value from each source (even-count message); for scheduler and worker, repeat witha=localhost:0, which parses but failsxce_cluster_config_validate()(port 0). (1.4) - harness/
xce_test --norc, each script ending in a connecting command (client): (a)set masters badwithXCE_CONTROLLER_PEERSunset → message says explicitset masters; (b) noset masters,XCE_CONTROLLER_PEERS=bad→ message says default fromXCE_CONTROLLER_PEERS; (c)set masters badwithXCE_CONTROLLER_PEERSset to a valid value → still says explicitset masters(explicit wins, 1.1). (1.1, 1.4) - controller with
XCE_CONTROLLER_PEERS=a=localhost:1,b=localhost:2,c=localhost:3: with--myname zz, assert a non-zero exit and the 1.6 message (nameszz, the variable, and the remedies); with--myname b, assert the "HA mode: 3 controller candidates" line and no 1.6 message; with--myname zz --peers a=localhost:1,b=localhost:2,c=localhost:3, assert no 1.6 message (explicit-flag behavior unchanged). (1.1, 1.6) - controller with
XCE_CONTROLLER_PEERS=""and no--peers: assert the "No --peers flag ... running as single-node primary" line. (1.7) - Help text (1.5, 1.7): run
xce_controller --help,xce_scheduler --helpandxcecd_virtual -h, and feedhelp settoxce_test --norc; assert each output namesXCE_CONTROLLER_PEERSand says that unsetting or emptying it opts out for one invocation. For xceadmin,test_cli.pyasserts the same name in each--controlleroption's--helpoutput (2.7). deploy-ha.shwrapper: rundeploy-ha.shwith the built controller binary and a peers list on free high ports (it refuses ports already in use,deploy-ha.sh~L204-225) into a scratch$TOP(generation only, nothing started), copy$TOP/scripts/xceadminwith its finalexecline replaced byprintenv XCE_CONTROLLER_PEERS XCEADMIN_CONTROLLER, and run the copy withXCE_CONTROLLER_PEERS=bogus:1in the caller's environment; assert it prints the deployment's own list indeploy-ha.sh'sPEERS_STRform (ctrl-1=host:port,ctrl-2=..., not the raw peers argument) and its first candidate (PEER_ARRAY[0], barehost:port). If the xceadmin wheel is missing (so no wrapper is generated), this case fails with a message naming/xce-build xceadmin, rather than being skipped. (4.1, 4.2)- Ambient-variable exposure of existing tests: the live xceadmin tests
and the
xce-test-*skill scripts pass--peers,--controllerorset mastersexplicitly (e.g.test_e2e_live_ha_push.py~L361/~L488), so a developer'sXCE_CONTROLLER_PEERScannot change them (1.1). Two existing scripts startxce_schedulerwith only--logfile:src/tools/test-version-startup-consistency.sh(~L302) andsrc/tools/test-dirty-fingerprint-startup-log-unchanged.sh. With the variable set, their scheduler takes the HA path instead of the single-controller path; they still pass, because they match the startup line by its version text, which the "HA cluster config" line also carries. Implementation addsunset XCE_CONTROLLER_PEERSat the top of both so they keep testing the path they were written for. Any new test that relies on the no-value default must do the same. - xceadmin (
test_cli.py): withXCE_CONTROLLER_PEERSandXCEADMIN_CONTROLLERboth set and an explicit--controller, the flag wins. (4.3; this runs the CLI directly rather than through the generated wrapper, which is sufficient because the wrapper only exports variables and passes its arguments through unchanged)
This script is a new addition to the File Structure Plan and is intended to be re-run on any future change to these five components' startup argument handling, not a one-time implementation check.