Skip to content

nerve config validate — CI-ready config bundle validation (7/13) - #210

Merged
pufit merged 2 commits into
mainfrom
config-refactor/07-validate
Aug 1, 2026
Merged

nerve config validate — CI-ready config bundle validation (7/13)#210
pufit merged 2 commits into
mainfrom
config-refactor/07-validate

Conversation

@alex-clickhouse

@alex-clickhouse alex-clickhouse commented Jul 28, 2026

Copy link
Copy Markdown
Collaborator

What

nerve config validate checks a whole config bundle and exits non-zero on any error, so it drops straight into CI on a config repo. It covers unknown keys, cron job and gate parsing, source sanity, ${VAR} resolvability, and schedules the scheduler would not actually run as written.

--portable-only drops the machine-local layers and judges the tracked layer alone. --strict-keys and --strict-env promote warnings to errors. It runs even when the config cannot otherwise load, which is the point.

Why

Every class of error here was previously found by a daemon at start-up, after the change had already merged and synced — leaving the instance on its old config with a log line nobody was reading. Moving the check before the merge is the whole value.

Schedules are the clearest case. A 5-field crontab the scheduler rejects (99 * * * *) is refused at run time and logged. A schedule that is neither a crontab nor an interval (hourly, @daily) is never complained about at run time at all — it silently never fires.

--portable-only exists because a machine-local override can mask an invalid shared value, and, more commonly, a broken local file condemns a shared bundle with nothing wrong with it. Validating the tracked layer on its own is the only question whose answer does not depend on which host ran the check.

Validation reads and parses; it does not execute candidate gate-plugin code. "An invalid bundle is refused" is not a guarantee you can make about code you had to run in order to judge it, so an unrecognised gate type is reported as unverified rather than accepted or rejected. Testing a plugin is the author's job.

Added in review

The validator rejected blank path values that the loader accepts. It refused
a blank workspace, cron.jobs_file, cron.system_file or
cron.gate_plugins_dir on the stated premise that Path("") is Path("."), so
a blank "means the directory the daemon happened to be started in". It does not:
_expand_path maps blank to None before it can become Path("."), and the
field takes its documented default. A single validator run showed both halves —
the log line resolving the blank to the default, then the error saying it
resolved to the working directory.

The shape that hits it is jobs_file: ${CRON_JOBS_FILE:-}, and the docs promote
${VAR:-default} as the fleet idiom. docs/config.md carried both readings in
two places, and the suite pinned both contracts with TestBlankPathSettingsMeanUnset
and TestWorkingDirectoryPaths green side by side.

Blank is no longer reported. An explicit . / ./ / ./. stays a hard error,
because those do survive the loader as the path they are.

The rule was also a hand-written four-key literal against fourteen path settings,
so gateway.ssl.cert accepted a dot that switches TLS on with a directory as the
certificate. The keys are now derived from the config dataclasses: one test walks
every Path-annotated field and fails if it is neither covered nor exempted with
a reason, a second fails if the rule names a field the config does not declare,
and a third asserts each covered key actually produces an error — so a key cannot
be listed and never read. Six settings that check turned up are now covered.

🤖 Generated with Claude Code

@alex-clickhouse alex-clickhouse changed the title nerve config validate — CI-ready config bundle validation nerve config validate — CI-ready config bundle validation (7/13) Jul 28, 2026
@alex-clickhouse
alex-clickhouse requested a review from Copilot July 28, 2026 10:00

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds CI-friendly configuration bundle validation (nerve config validate) and supporting validation primitives to hard-fail on structurally invalid config bundles (cron schedules, gate specs, wrong-shaped layers, unsafe cwd-resolving paths), while remaining intentionally lenient about unset ${ENV_VAR} secrets unless explicitly requested.

Changes:

  • Introduces validate_config_bundle() with detailed reporting and a new nerve config validate CLI command.
  • Tightens cron schedule and gate-spec validation (including new interval parsing semantics and gate spec field allowlists via spec_keys).
  • Expands test coverage and documentation for validation behavior, strictness flags, and plugin non-execution guarantees.

Reviewed changes

Copilot reviewed 11 out of 11 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
tests/test_cron_gates.py Adds regression tests for gate-shape normalization/logging behavior.
tests/test_config_validate.py Adds comprehensive tests for config-bundle validation semantics and CLI behavior.
tests/test_config_sources.py Ensures CLI emits clean errors for config mistakes (including bad backend) while preserving tracebacks for internal defects.
nerve/cron/service.py Splits interval parsing to expose _interval_seconds() for validation without changing daemon fallback behavior.
nerve/cron/jobs.py Prevents accidental normalization of wrong-shaped run_if/skip_when_idle, and logs+ignores invalid shapes instead of raising.
nerve/cron/gates.py Adds spec_keys allowlists and improves build_gate() robustness for non-string/unhashable type.
nerve/config_validate.py New validation module: layer reading, cwd-path footgun checks, cron/gate/schedule validation, and reporting.
nerve/cli.py Adds nerve config validate command and refines “clean error vs traceback” behavior in the CLI group callback.
docs/cron.md Documents schedule fallback vs validation behavior, and clarifies that validation does not execute plugin gates.
docs/config.md Adds a full “Validating Configuration” section including CI guidance and flags.
config.example.yaml Updates cron comments and documents cron.auto_reload example.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread nerve/config_validate.py Outdated
@alex-clickhouse
alex-clickhouse force-pushed the config-refactor/07-validate branch from 726ca79 to 12c7340 Compare July 28, 2026 12:11
@alex-clickhouse
alex-clickhouse force-pushed the config-refactor/07-validate branch from 12c7340 to 8c7a5fd Compare July 28, 2026 12:36
@alex-clickhouse
alex-clickhouse force-pushed the config-refactor/07-validate branch from 8c7a5fd to e171c79 Compare July 28, 2026 13:14
@alex-clickhouse
alex-clickhouse force-pushed the config-refactor/07-validate branch from e171c79 to a4016f2 Compare July 28, 2026 14:23
@alex-clickhouse
alex-clickhouse force-pushed the config-refactor/07-validate branch from a4016f2 to 09a9260 Compare July 28, 2026 14:54
@alex-clickhouse
alex-clickhouse force-pushed the config-refactor/07-validate branch 2 times, most recently from b923d11 to aa8026e Compare July 28, 2026 16:03
@alex-clickhouse
alex-clickhouse force-pushed the config-refactor/07-validate branch 2 times, most recently from 2c7f820 to ee55d3e Compare July 28, 2026 18:40
@alex-clickhouse
alex-clickhouse force-pushed the config-refactor/07-validate branch from ee55d3e to 51b1ebb Compare July 29, 2026 08:29
@alex-clickhouse
alex-clickhouse force-pushed the config-refactor/07-validate branch 3 times, most recently from 8947dbc to e165653 Compare July 29, 2026 10:31
@alex-clickhouse
alex-clickhouse force-pushed the config-refactor/07-validate branch from e165653 to 37d5fa6 Compare July 29, 2026 13:28
@alex-clickhouse
alex-clickhouse marked this pull request as ready for review July 29, 2026 15:25
@alex-clickhouse
alex-clickhouse force-pushed the config-refactor/07-validate branch from 37d5fa6 to 7a5ffb6 Compare July 30, 2026 08:01
@alex-clickhouse
alex-clickhouse force-pushed the config-refactor/07-validate branch from 7a5ffb6 to 51fb706 Compare July 30, 2026 10:06
@alex-clickhouse
alex-clickhouse force-pushed the config-refactor/07-validate branch from 51fb706 to 8da134a Compare July 31, 2026 08:19
@alex-clickhouse
alex-clickhouse force-pushed the config-refactor/07-validate branch from 8da134a to 2826475 Compare July 31, 2026 08:36
@pufit
pufit force-pushed the config-refactor/07-validate branch from 2826475 to d2fc670 Compare August 1, 2026 02:33
@pufit
pufit force-pushed the config-refactor/07-validate branch from d2fc670 to 436cb05 Compare August 1, 2026 02:34
@pufit
pufit force-pushed the config-refactor/07-validate branch from 436cb05 to e710dfc Compare August 1, 2026 02:46
@pufit
pufit force-pushed the config-refactor/07-validate branch from e710dfc to 7f14e69 Compare August 1, 2026 02:58
@pufit
pufit force-pushed the config-refactor/07-validate branch from 7f14e69 to 5439ec5 Compare August 1, 2026 02:58
Base automatically changed from config-refactor/06-file-watcher to main August 1, 2026 02:58
alex-clickhouse and others added 2 commits July 31, 2026 22:58
Hard-failing validation over a whole bundle — unknown keys, cron job and gate
parsing, source sanity and ${VAR} resolvability — with a non-zero exit so it
drops straight into a config repo's CI.

Validation reads and parses; it never loads the bundle's own code. Importing a
gate plugin to check it would mean the bundle had already executed by the time we
decided it was unfit, so an unrecognized gate type is reported as unverified
rather than accepted or rejected. Built-in gate specs are built, which is what
type-checking a gate spec means.

Two silent-acceptance gaps are closed. A blank path setting is refused with a
message saying what it would actually do: cron.gate_plugins_dir set to "" is not
"no directory" but the daemon's working directory, every .py in which is imported
and executed at startup and on every cron reload. And a schedule the scheduler
will not run now fails CI — a bad crontab, a string that is neither crontab nor
interval, a zero interval, or a non-string that would raise past every handler
the daemon has. Source schedules were not checked at all and now are, through the
same helper the daemon uses, so the validator and the runtime cannot disagree
about what is valid.

Co-Authored-By: Claude <noreply@anthropic.com>
The validator refused a blank `workspace`, `cron.jobs_file`,
`cron.system_file` or `cron.gate_plugins_dir` on the premise that
`Path("")` is `Path(".")`, so a blank value "means the directory the
daemon happened to be started in". It does not. `_expand_path` maps
blank to None before it can become `Path(".")`, and the field takes its
documented default — which the validator's own run demonstrated, logging
the resolved default path and then erroring to say the value resolved to
the working directory.

So the same bundle loaded correctly and failed CI. The shape that hits it
is `jobs_file: ${CRON_JOBS_FILE:-}`, and docs/config.md promotes
`${VAR:-default}` as the fleet idiom. `docs/config.md` also carried both
readings, in two places, and the tests pinned both contracts with
`TestBlankPathSettingsMeanUnset` and `TestWorkingDirectoryPaths` green
side by side.

Blank is no longer reported. The explicit `.` / `./` / `./.` check stays
a hard error, because those do survive the loader as the path they are.

The rule was also a hand-written four-key literal against fourteen path
settings, so `gateway.ssl.cert` accepted a dot that switches TLS on with
a directory as the certificate. The keys are now derived from the config
dataclasses: one test walks every `Path`-annotated field and fails if it
is neither covered nor exempted with a reason, a second fails if the rule
names a field the config does not declare, and a third asserts each
covered key actually produces an error, so a key cannot be listed and
never read. Six settings that check turned up are now covered.

Co-Authored-By: Claude <noreply@anthropic.com>
@pufit
pufit force-pushed the config-refactor/07-validate branch from 5439ec5 to 77b7855 Compare August 1, 2026 02:58
@pufit
pufit merged commit 8e5ae47 into main Aug 1, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants