Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
157 changes: 150 additions & 7 deletions docs/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -156,12 +156,16 @@ the quietest, because nothing reads it.
`--portable-only` judges the tracked `<workspace>/config/settings.yaml` layer on
its own. Use it for a change headed to a shared repo: a local override can
otherwise mask an invalid shared value, and, more often, a broken local file can
fail a shared bundle that has nothing wrong with it. Pass `--workspace` with it,
because with the machine layers dropped there is nothing left to read the
workspace location from and it falls back to the default tree. It fails outright
if it opened no file under the workspace's `config/`, so an empty directory, a
`settings.yml` typo or a `settings.yaml` left at the repo root cannot pass as
clean. Every run names the layers it read, in absolute paths:
fail a shared bundle that has nothing wrong with it. Cron is covered too. A
workspace carrying no jobs normally falls back to the machine-local
`~/.nerve/cron`, which is right for an un-migrated install but wrong here, so under
`--portable-only` only the repo's own `config/cron/` counts and anything skipped is
named in the report. Pass `--workspace` as well, because with the machine layers
dropped there is nothing left to read the workspace location from and it falls back
to the default tree. It fails outright if it opened no file under the workspace's
`config/`, so an empty directory, a `settings.yml` typo or a `settings.yaml` left
at the repo root cannot pass as clean. Every run names the layers it read, in
absolute paths:

```
[info] portable layer: /home/you/config-repo/config/settings.yaml
Expand All @@ -182,10 +186,10 @@ jobs:
- uses: astral-sh/setup-uv@v6
# nerve is not published to PyPI: the bare name is an unrelated project.
- run: uv pip install --system "nerve @ git+https://github.com/ClickHouse/nerve"
- run: nerve config validate --workspace . --portable-only --strict-keys
# Add --assume-lockdown if any instance served by this repo is locked and
# the flag comes from the environment. Without it CI checks the unlocked
# view and the lockdown checks never run. See "Lockdown" below.
- run: nerve config validate --workspace . --portable-only --strict-keys
```

Installing from the default branch keeps the validator at or ahead of your
Expand All @@ -195,6 +199,11 @@ is too old to read shows up as a startup warning on the instance and in `nerve
doctor`. If a key is ever retired upstream, `--strict-keys` flags it here first;
drop the key, or install the ref you deploy (`nerve @ git+...@v1.2.3`) instead.

`nerve config init-repo` scaffolds this workflow, plus a
[gitleaks](https://github.com/gitleaks/gitleaks) scan ahead of it to catch a
credential pasted into a tracked file. See [Setting up the config
repo](#setting-up-the-config-repo) for the generated file and the end-to-end setup.

## Hot-Reload

Many config changes apply without a restart. Not all do, and a config file
Expand Down Expand Up @@ -330,6 +339,140 @@ reports it too. `status` is the field to branch on:
It describes *this call*. Whether the daemon is running the revision currently on
disk is a different question, answered by `GET /api/config/sync` below.

## Setting up the config repo

The workspace *is* the config repo: its root holds `config/` (settings, cron) and
`skills/`. To put it under review and sync it to an instance, turn the workspace
into a git repo with a GitHub remote, add CI validation, and optionally enable
lockdown.

**1. Scaffold the repo files.** From the instance, or anywhere the workspace is
checked out:

```bash
nerve config init-repo # scaffolds into the resolved workspace
nerve config init-repo --workspace ./ws # or an explicit path; --dry-run to preview
```

This writes four files, never overwriting an existing one:

- `.github/workflows/validate-config.yml`: the CI check, shown below.
- `.gitignore`: keeps `config.local.yaml`, `.env`, `*.migrated`, databases and the
like out of the shared repo. Secrets belong in the environment, referenced as
`${ENV_VAR}`.
- `README.md`: a short explainer of the PR-based flow.
- `config/settings.yaml`: the commented portable settings scaffold, if the
workspace has none. An instance's workspace already has one; a bare directory
does not, and the CI check fails a repo with no `config/` at all rather than
passing it.

**2. Create the GitHub repo and push.** The command prints these rather than
running them, since they need a repo name and auth:

```bash
cd <workspace>
git init && git add -A && git commit -m "Initial Nerve config"
gh repo create <org>/nerve-config --private --source=. --remote=origin --push
```

**3. CI validation.** The scaffolded workflow scans for committed secrets and
validates the bundle on every PR. It needs no secrets or tokens of its own, since
`ClickHouse/nerve` is public:

```yaml
name: validate-config

# Validates the Nerve config bundle on every PR so a broken change can't be
# merged and then synced onto a live instance. See docs/config.md.

on:
pull_request:
push:
branches: [main]

permissions:
contents: read

jobs:
validate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5

# Backstop for the one thing review is worst at spotting: a credential
# pasted into a tracked file. Secrets belong in the environment and are
# referenced from settings.yaml as ${ENV_VAR}. Runs before anything else
# is fetched, so it scans this repo and nothing else, and needs no token
# of its own. The image is used directly because the marketplace action
# asks organizations for a license key.
#
# The tag is pinned because an argument renamed in a later gitleaks would
# fail every config repo at once, with no pull request anywhere to explain
# it. (From 8.19 this subcommand is spelled `gitleaks dir <path>`; bump the
# tag and the args together.) A false positive on a high-entropy string is
# silenced with a trailing `# gitleaks:allow`, or repo-wide in a
# .gitleaks.toml.
- name: Scan for committed secrets
uses: docker://ghcr.io/gitleaks/gitleaks:v8.18.4
with:
args: detect --no-git --source=/github/workspace --redact

# nerve is not published to PyPI, so this installs from git; the bare name
# would fetch an unrelated project. Tracking the default branch keeps the
# validator at or ahead of the instance, which is the safe direction for
# --strict-keys below: a newer validator knows every key the instance
# reads. To check against one release instead, append a ref (nerve@v1.2.3).
- uses: astral-sh/setup-uv@v6
- run: uv pip install --system "nerve @ git+https://github.com/ClickHouse/nerve"

# The config repo root IS the workspace (it holds config/ and skills/).
#
# --portable-only judges the tracked bundle on its own: no machine-local
# config.yaml, and no falling back to a machine-local cron directory when
# this repo carries no jobs. It fails outright if it opened no file under
# config/ at all, so a layout mistake cannot pass as clean.
# --strict-keys makes a misspelled key block the PR rather than warn.
#
# Unset ${ENV_VAR} secret refs are reported as info (CI has no secrets);
# add --strict-env to require them. If any instance served by this repo is
# locked through ${NERVE_LOCKDOWN}, add --assume-lockdown as well, or CI
# only ever checks the unlocked view that instance never loads.
- name: Validate config bundle
run: nerve config validate --workspace . --portable-only --strict-keys
```

That is the file verbatim, comments included, because they explain each flag where
the person editing it will be looking. The command is the same one you can run
locally, so a local `nerve config validate --workspace . --portable-only
--strict-keys` gives the verdict CI will. Note that the *config* repo is private;
only nerve itself is public.

`init-repo` never overwrites an existing workflow, so a repo scaffolded earlier
keeps whatever it has; compare it against the file above after a nerve upgrade.

Gate plugins need nothing installed here: validation never loads them (see
[Validating Configuration](#validating-configuration)), so their imports are never
resolved in CI.

**4. Point the instance at the remote and enable sync.** The workspace is already
a git clone of the repo, so the remote and credentials come from git itself. Turn
on periodic pulls in `workspace/config/settings.yaml`:

```yaml
workspace_sync:
enabled: true
branch: main
interval_minutes: 5
```

**5. Optionally lock it down.** Once secrets are in the environment as `${ENV_VAR}`
refs, set `lockdown: true` in `settings.yaml` so the instance only ever runs the
reviewed, merged remote. See [Lockdown](#lockdown-remote-only-read-only).

From here the loop is: open a PR, CI validates, review and merge, the instance
syncs and reloads. The agent proposes its own changes the same way, through the
`nerve-workspace` skill.

## Git-Backed Workspace Sync

The workspace can be a git repository whose remote is a shared **config repo**.
Expand Down
95 changes: 84 additions & 11 deletions nerve/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -1469,27 +1469,100 @@ def config_validate(
portable_only: bool, assume_locked: bool,
) -> None:
"""Validate the configuration bundle. Non-zero exit on any error (CI-ready)."""
from nerve.config_validate import validate_config_bundle
from nerve.config_validate import render_result, validate_config_bundle

config_dir = Path(ctx.obj["config_dir"])
result = validate_config_bundle(
config_dir, workspace_override=workspace,
strict_env=strict_env, strict_keys=strict_keys,
portable_only=portable_only, assume_locked=assume_locked,
)
for msg in result.info:
click.echo(f"[info] {msg}")
for msg in result.warnings:
click.secho(f"[WARN] {msg}", fg="yellow")
for msg in result.errors:
click.secho(f"[ERR] {msg}", fg="red")
if result.ok:
click.secho("Config OK", fg="green")
else:
click.secho(f"Config invalid: {len(result.errors)} error(s)", fg="red")
# Same renderer as `python -m nerve.config_validate` (the no-install path),
# just colorized here.
lines, summary = render_result(result)
_SEVERITY_COLORS = {"[WARN]": "yellow", "[ERR]": "red"}
for line in lines:
click.secho(line, fg=_SEVERITY_COLORS.get(line.split(" ", 1)[0]))
click.secho(summary, fg="green" if result.ok else "red")
if not result.ok:
ctx.exit(1)


@config_group.command("init-repo")
@click.option(
"--workspace", "workspace", type=click.Path(), default=None,
help="Workspace to scaffold (default: the resolved workspace).",
)
@click.option(
"--dry-run", is_flag=True, help="Show what would be created without writing.",
)
@click.pass_context
def config_init_repo(
ctx: click.Context, workspace: str | None, dry_run: bool,
) -> None:
"""Scaffold the workspace into a shareable git config repo.

Drops a CI validation workflow, a secrets-aware .gitignore, and a README into
the workspace (never overwriting existing files), then prints the remaining
git/gh and instance-config steps. Safe to re-run.
"""
from nerve.config_repo import scaffold_config_repo

if workspace:
ws = Path(workspace).expanduser()
else:
config = ctx.obj["config"]
if config is None:
raise click.ClickException(
"Config could not be loaded; pass --workspace or run 'nerve doctor'."
)
ws = Path(config.workspace)

if not ws.is_dir():
raise click.ClickException(f"Workspace does not exist: {ws}")

result = scaffold_config_repo(ws, dry_run=dry_run)

verb = "Would create" if dry_run else "Created"
for rel in result.created:
click.secho(f" {verb}: {rel}", fg="green")
for rel in result.skipped:
click.secho(f" Skipped (exists): {rel}", fg="yellow")
if not result.created:
click.echo("Config-repo scaffold already in place — nothing to create.")

# Remaining steps the CLI can't do for you (they need a repo URL + auth).
click.secho("\nNext steps:", bold=True)
click.echo(f" cd {ws}")
if not result.is_git_repo:
click.echo(" git init && git add -A && git commit -m 'Initial Nerve config'")
else:
click.echo(" git add -A && git commit -m 'Add config-repo scaffold'")
click.echo(
" gh repo create <org>/nerve-config --private --source=. "
"--remote=origin --push"
)
click.echo(
"\nCI validates every PR out of the box (no secrets needed) — see\n"
".github/workflows/validate-config.yml. It installs nerve from the\n"
"default branch, which stays at or ahead of this instance; append a ref\n"
"to that install line to check against one release instead."
)
click.echo(
"\nOn the instance, enable sync in workspace/config/settings.yaml:\n"
" workspace_sync:\n"
" enabled: true\n"
" branch: main\n"
" interval_minutes: 5\n"
"Then verify — the same check CI runs, so a pass here means a green PR:\n"
f" nerve config validate --workspace {ws} --portable-only --strict-keys\n"
"Drop --portable-only to include this machine's config.yaml and\n"
"config.local.yaml, i.e. what the daemon itself loads.\n"
"When ready to lock down, set 'lockdown: true' in settings.yaml (move\n"
"secrets to ${ENV_VAR} first — see docs/config.md)."
)


@main.group()
def codex() -> None:
"""Inspect and authenticate the Codex integration."""
Expand Down
7 changes: 7 additions & 0 deletions nerve/config_pr.py
Original file line number Diff line number Diff line change
Expand Up @@ -727,6 +727,13 @@ def propose_config_change(
# one, and the boxes that merge it refuse to load. The masking also runs
# the other way — an explicit machine-local cron.jobs_file wins, and a
# proposed config/cron/jobs.yaml is then never opened at all.
#
# Still not --strict-keys, which the config repo's own CI does add, so a
# typo'd key it would reject only warns here and the PR still opens. That
# asymmetry is on purpose — the reviewer sees the red check and the
# explanation with the change in front of them, which is a better place to
# settle "is this key real" than a refusal here with no diff to look at.
# Erring the other way would block proposals on keys a newer nerve knows.
from nerve.config_validate import validate_config_bundle
errors = [
e for e in validate_config_bundle(
Expand Down
81 changes: 81 additions & 0 deletions nerve/config_repo.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
"""Scaffold a workspace into a shareable git config repo.

``nerve config init-repo`` drops the files a config repo needs — the CI
validation workflow, a secrets-aware ``.gitignore``, a README, and a portable
settings layer — into the workspace, after which the CLI prints the remaining
manual git/``gh`` + instance steps. Scaffolding is **idempotent**: an existing
file is never overwritten (it's reported as skipped), so re-running is safe and
won't clobber local edits.

The workspace root *is* the config repo root (it holds ``config/`` and
``skills/``), so these files land at the top level of the workspace.
"""

from __future__ import annotations

import importlib.resources
from dataclasses import dataclass, field
from pathlib import Path

# Destination path (relative to the workspace root) -> source path under
# nerve/templates/. Order is display order.
_SCAFFOLD: dict[str, str] = {
".github/workflows/validate-config.yml": "config-repo/validate-config.yml",
".gitignore": "config-repo/gitignore",
"README.md": "config-repo/README.md",
# The same commented scaffold `nerve init` writes. A repo with no config/ at
# all fails the workflow above on its very first commit: that job validates
# the portable layer only, and finding no file to open is an error there, not
# a pass. An instance's workspace already has this file, and it is skipped.
"config/settings.yaml": "config/settings.yaml",
}


@dataclass
class ScaffoldResult:
"""What ``scaffold_config_repo`` did (or would do, under ``dry_run``)."""

created: list[str] = field(default_factory=list)
skipped: list[str] = field(default_factory=list)
is_git_repo: bool = False


def _template_dir() -> Path:
"""Resolve nerve/templates/ in both source and installed layouts."""
try:
ref = importlib.resources.files("nerve") / "templates"
p = Path(str(ref))
if p.is_dir():
return p
except (TypeError, FileNotFoundError):
pass
p = Path(__file__).parent / "templates"
if p.is_dir():
return p
raise FileNotFoundError("nerve templates not found")


def scaffold_config_repo(workspace: Path, dry_run: bool = False) -> ScaffoldResult:
"""Write the config-repo scaffold files into ``workspace``.

Never overwrites an existing file (reported in ``skipped``). With
``dry_run=True`` nothing is written but the created/skipped split is still
computed. Returns a :class:`ScaffoldResult`.
"""
workspace = Path(workspace)
tmpl = _template_dir()
result = ScaffoldResult(is_git_repo=(workspace / ".git").exists())

for rel, src_name in _SCAFFOLD.items():
dst = workspace / rel
if dst.exists():
result.skipped.append(rel)
continue
result.created.append(rel)
body = (tmpl / src_name).read_text(encoding="utf-8")
if dry_run:
continue
dst.parent.mkdir(parents=True, exist_ok=True)
dst.write_text(body, encoding="utf-8")

return result
Loading
Loading