Xianyun
Xianyun starts a local Kubernetes cluster, installs what your project’s config lists into it, and sets up the host so the cluster is usable: a trusted dev certificate authority, hostnames in /etc/hosts, a kube context, and an optional pull-through image cache. It’s built for validation work, where you bring an environment up, check a change against it, and tear it down again, over and over.
The main way to use it is through an AI agent. xianyun-mcp is an MCP server that hands a reviewed set of Xianyun commands to your agent as tools. The same commands are available from a terminal as xianyun, so you and the agent work against the same environment with the same vocabulary.
Two binaries
| Binary | What it is |
|---|---|
xianyun-mcp | The MCP server. It speaks MCP on standard input and output, and each tool call runs the matching xianyun command and returns its output. |
xianyun | The command-line tool. Every MCP tool maps to one of its commands. |
xianyun-mcp is the whole CLI plus the MCP server, because tool calls re-run the same executable. You can install just xianyun-mcp and still get the terminal commands.
Where to start
- Use it with an agent. Install, then connect an agent. Read Confirmation and gated tools before you rely on the destructive tools.
- Use it from a terminal. Set up a first project, then the command reference.
- Write a config. Configuration overview and the reference.
- Contribute or audit. Project documentation holds the architecture decisions, the specification, the user stories, and the threat model. The API reference is generated by rustdoc.
Status
Xianyun is alpha software. It’s a Rust rewrite of Caproni, the Go tool it forked from, and parity isn’t complete. Read Known limitations before you plan a workflow around it. The most important ones: edit mode is only partly implemented, and the built-in dev CA and Traefik TLS overlays haven’t been deployed end to end.
This book describes the xianyun binary. The documentation in the repository’s docs/ directory describes the original Go binary and its names.
About the name
Xianyun (閑雲, pinyin Xián Yún) means “idle cloud”. It’s also the name of the Genshin Impact adeptus who designs and builds tools, like the Steam Bird, for others to use safely, which is a fair description of the job.
Installation
There are no published releases yet. You build Xianyun from the repository.
Requirements
Xianyun orchestrates other programs rather than reimplementing them, so they need to be on your PATH.
| Tool | Needed for |
|---|---|
docker and k3d | The k3d driver, which is what driver: auto selects on Linux |
colima | The colima driver, which driver: auto selects on macOS |
kubectl | Waiting for the cluster and applying manifests |
helm, kustomize | Deployers (see Known limitations) |
git | Cloning and updating repositories |
mirrord or skaffold | Edit mode reloaders |
zot | A managed registry cache (registry_mirror.managed: true) |
certutil | Trusting the dev CA in Firefox and other NSS databases |
Run xianyun doctor once it’s built to see what’s missing. The repository’s Nix development shell provides most of them.
Supported platforms are x86_64 Linux, aarch64 Linux, and aarch64 macOS. The test suite was last measured on Linux. The macOS numbers in the project notes are older and weren’t re-measured.
Build with Nix
$ nix build .#xianyun # the CLI
$ nix build .#xianyun-mcp # the MCP server (includes the CLI)
$ ./result/bin/xianyun-mcp --help
Each package builds one binary. Install xianyun-mcp if you mostly want the agent workflow.
Build with Cargo
You need a Rust toolchain that supports edition 2024 (1.85 or newer).
$ nix develop -c cargo build --release -p xianyun-cli -p xianyun-mcp
$ ls target/release/xianyun target/release/xianyun-mcp
Without Nix, drop the nix develop -c prefix and install the tools from the table yourself.
Copy the binaries somewhere on your PATH, or point your agent at the full path.
Check the install
$ xianyun version
caproni version 0.1.0
commit: unknown
built: unknown
The command says “caproni” because several user-facing strings haven’t been renamed yet. The same goes for parts of --help. This is tracked as GAP-008 in the specification.
Your first project
This page uses the terminal. The same commands are what your agent calls, so it’s worth running them once yourself to see what they print.
Write a configuration
Create xianyun.yaml in your project directory:
version: 2
cluster:
driver: auto # k3d on Linux, colima on macOS
network:
hostname: myapp.test
version and cluster are the only required keys, and cluster.network is required inside cluster. See the configuration overview for everything else.
Xianyun looks for xianyun.yaml in the current directory and then in each parent, stopping at your home directory. If it finds only caproni.yaml, it uses that, so existing Caproni projects work unchanged.
Validate it
$ xianyun config validate
✓ Configuration is valid (v2)
• 0 deployers configured
• 0 repositories defined
• 0 reloaders configured
A mistake produces a schema error that names the field as a JSON pointer, for example /cluster/driver: "nonsense" is not one of [...], and exits non-zero.
Check your machine
$ xianyun doctor
doctor reports problems it knows about. It currently runs two diagnostics, cluster domain and orphaned mirror copies, which is fewer than the Go tool had.
Bring the cluster up and down
$ xianyun up # start the cluster and wait for its API to answer
$ xianyun status # show the cluster state
$ xianyun kubectl get nodes
$ xianyun down # stop it, keep its state
$ xianyun destroy # delete it and its data
xianyun kubectl runs your kubectl with KUBECONFIG pointing at this project’s cluster, so you don’t touch your default context. xianyun set-kube-context merges the cluster into your own kubeconfig if you’d rather use it directly.
down and destroy succeed when no cluster exists, so they’re safe to repeat. With driver: external they return an error instead, because Xianyun doesn’t own that cluster’s lifecycle.
Optional host setup
$ xianyun ca trust # install the dev CA (asks for sudo)
$ xianyun update-etc-hosts --dry-run
$ xianyun update-etc-hosts # write hostnames to /etc/hosts (asks for sudo)
Both change your machine outside the project, which is why they’re gated when an agent calls them. See Trusting the dev CA.
Next
Connect an agent so it can run these for you.
Connect an agent
An agent reaches Xianyun by launching xianyun-mcp and talking MCP to it over standard input and output. This page covers the setup. How the MCP server works explains what you get.
The one rule: working directory
Every tool call resolves its configuration the way the terminal command does, by searching upward from the working directory of the xianyun-mcp process. If the client starts the server somewhere else, every call fails with no caproni.yaml found in current directory or any parent directory up to home directory.
Start the server in your project directory. Check where your client starts it; opencode has a cwd option for exactly this.
opencode
Add the server to opencode.json in your project, or to your global config:
{
"$schema": "https://opencode.ai/config.json",
"mcp": {
"xianyun": {
"type": "local",
"command": ["xianyun-mcp"],
"enabled": true
}
}
}
If xianyun-mcp isn’t on your PATH, use the full path as the first element of command. opencode registers each MCP tool with the server name as a prefix, so xianyun_status appears as xianyun_xianyun_status.
Claude Code
$ claude mcp add xianyun -- xianyun-mcp
Run it from your project directory.
Other clients
xianyun-mcp can edit the configuration of four editors for you. Each has enable, disable, and list subcommands that change that client’s own config file:
$ xianyun-mcp mcp claude list # Claude Desktop
$ xianyun-mcp mcp vscode list
$ xianyun-mcp mcp cursor list
$ xianyun-mcp mcp zed list
For any other MCP client, register a local server whose command is xianyun-mcp and which speaks stdio. The server also accepts xianyun-mcp mcp start, which is the same thing spelled out.
Prefer stdio. The HTTP transport has open security problems; see Security.
Check that it works
Ask your agent to call xianyun_version, then xianyun_config_validate. A successful call returns the command’s output and exit code:
{
"exit_code": 0,
"stderr": "",
"stdout": "✓ Configuration is valid (v2)\n • 0 deployers configured\n • 0 repositories defined\n • 0 reloaders configured\n\n"
}
If the call fails instead, see Troubleshooting.
Tell the agent how to use it
Agents behave better when the project says what the tools are for. A snippet for your AGENTS.md:
## Local cluster
Use the `xianyun` MCP tools to manage the local Kubernetes environment.
- Call `xianyun_config_validate` before `xianyun_up`, and `xianyun_status` after it.
- Read `xianyun_logs` and `xianyun_doctor` output before retrying a failed step.
- Ask me before calling `xianyun_destroy` or `xianyun_update-etc-hosts`.
- Don't edit `xianyun.yaml` hooks without telling me; hooks run commands on my machine.
The tool names above are what the server exports. Your client may add a prefix.
How the MCP server works
xianyun-mcp exposes 20 of the CLI’s commands as MCP tools. The list is a reviewed allowlist, not the whole command tree, and a test fails the build if a new command is added without being classified.
One call, one command
When a client calls a tool, the server runs the matching xianyun command in its own working directory and returns what it printed:
{ "stdout": "...", "stderr": "...", "exit_code": 0 }
A non-zero exit code also marks the result as an error. The executable that runs is xianyun-mcp itself, called with the matching subcommand, so there’s no second binary to keep in step.
Every tool takes the same two inputs:
| Input | Type | Meaning |
|---|---|---|
args | list of strings | Positional arguments, for example process names for xianyun_logs |
flags | object | Named options, with underscores in place of dashes: {"dry_run": true} for --dry-run |
The tool reference lists the flags each tool accepts.
Three tiers
| Tier | Tools | Behaviour |
|---|---|---|
| Read-only | 7 | Report state. Safe to call freely. |
| State-changing | 8 | Change something local and reversible: start a cluster, pull repositories, merge a kube context. They run without a prompt. |
| Gated | 5 | Tear down state, rewrite /etc/hosts, or touch the trust store. They need confirmation first. |
What isn’t exposed
Six commands are left out on purpose.
toolruns any executable you name. This is the shape of glab #8153, where an unreviewed command became an arbitrary-execution primitive.kubectlforwards every argument to the realkubectl, with no schema to check them against.k9sneeds a terminal and never returns.sternstreams until interrupted.xianyun_logscovers the same need.runsupervises edit-mode processes until interrupted, which doesn’t fit a request and response call.edit-mode-process-supervisoris internal plumbing thatrunstarts.
You can still run all six from a terminal. For kubectl, xianyun kubectl get pods is the same command your agent can’t run.
Transports
Standard input and output is the default and the one to use. xianyun-mcp with no arguments, or xianyun-mcp mcp start, serves it.
Streamable HTTP is xianyun-mcp mcp stream. Read Security first: it listens on all interfaces by default and has no authentication.
xianyun-mcp mcp tools writes the current tool list to ./mcp-tools.json, which is what the repository’s snapshot test checks against.
Side effects to know about
xianyun_ca_inspectis in the state-changing tier rather than the read-only one because it creates the dev CA’s key pair on disk the first time it runs.xianyun_up,xianyun_deploy, andxianyun_updatecan run lifecycle hooks from your configuration, and the configuration can come from remote fragments and cloned repositories. Treat the config as code. See Security.
Tool reference
Tool names are exactly what the server exports. Your client may prefix them with the server name. Flags go in the flags object with underscores, and positional arguments go in args.
Every tool returns stdout, stderr, and exit_code.
Read-only
| Tool | Command | Flags |
|---|---|---|
xianyun_version | xianyun version | none |
xianyun_status | xianyun status | none |
xianyun_doctor | xianyun doctor | none |
xianyun_logs | xianyun logs [PROC_NAMES]... | tail (integer, default 25), no_log_prefix (boolean). Process names go in args. |
xianyun_config_validate | xianyun config validate | allow_deprecated (boolean) |
xianyun_config_print | xianyun config print | none |
xianyun_registry_status | xianyun registry status | output: text or json |
xianyun_config_print expands variables, including any environment variable the config references, and prints the result. Don’t point it at a config you don’t trust. See Security.
State-changing
These run without a prompt.
| Tool | Command | Flags |
|---|---|---|
xianyun_up | xianyun up | none |
xianyun_deploy | xianyun deploy | repo (string, a deployer name), update (boolean, pull repositories first) |
xianyun_update | xianyun update | verify_only, quiet, stash (booleans), repo, skip (strings) |
xianyun_set-kube-context | xianyun set-kube-context | dry_run, remove, use_context (booleans), kubeconfig (string) |
xianyun_config_migrate | xianyun config migrate | dry_run, force, skip_backup (booleans) |
xianyun_registry_start | xianyun registry start | none |
xianyun_registry_stop | xianyun registry stop | none |
xianyun_ca_inspect | xianyun ca inspect | none. Creates the CA key pair on first use. |
xianyun_config_migrate currently only reports that a version 2 configuration is already the latest.
Gated
These need confirmation before they run.
| Tool | Command | What the prompt warns about |
|---|---|---|
xianyun_down | xianyun down | Stops the cluster |
xianyun_destroy | xianyun destroy | Deletes the cluster and all its data, and can’t be undone |
xianyun_update-etc-hosts | xianyun update-etc-hosts | Rewrites /etc/hosts. Flags: dry_run, remove (booleans), ip (string). Typically needs sudo. |
xianyun_ca_trust | xianyun ca trust | Installs the dev CA in the system trust store |
xianyun_ca_untrust | xianyun ca untrust | Removes it |
Gating applies to a dry run too. xianyun_update-etc-hosts with dry_run set still goes through the prompt.
Names with dashes
xianyun_update-etc-hosts and xianyun_set-kube-context keep the dash from the command name, so they’re the only two tool names that aren’t plain identifiers. If your client can’t call them, check how it lists them.
Confirmation and gated tools
Five tools can do damage that’s awkward to undo: down, destroy, update-etc-hosts, ca trust, and ca untrust. The server doesn’t run them on the first call. It asks the client to confirm with you.
How the handshake works
The server uses MCP’s multi-round-trip request mechanism, which relies on elicitation, the protocol’s way for a server to ask the user a question through the client.
- The agent calls a gated tool. The server doesn’t run it. It returns an “input required” result carrying a prompt, for example “Really run
xianyun destroy? This destroys the local cluster and deletes all its data. This cannot be undone.” - Your client shows the prompt to you.
- If you accept, the client retries the call with your answer attached, and the command runs.
- If you decline, the server returns exit code 1 and
aborted at the user's request, and nothing happens.
Your client has to support it
The mechanism needs MCP protocol version 2026-07-28 or newer and a client that declares elicitation. A client that can’t answer gets an error, and the command doesn’t run:
tool 'xianyun_update-etc-hosts' failed to execute: config error: middleware asked the
client for input, which requires MCP protocol 2026-07-28 or newer; this client
negotiated 2025-11-25
That’s what a client on protocol 2025-11-25 sees. Check what your client negotiates. If it’s older, the gated tools are unavailable through MCP, which fails safe. Run the matching xianyun command in a terminal instead.
Even a dry run goes through the gate, so xianyun_update-etc-hosts with dry_run set returns the same error on an older client.
What the gate doesn’t do
The gate trusts whatever answer the client sends. It doesn’t remember that it asked. A client that attaches {"confirm": {"action": "accept"}} to the first call skips the prompt, and the command runs. That was measured on 2026-10-13 against xianyun_down.
An AI model can’t set protocol fields through ordinary tool arguments, so the realistic risk is a hostile or buggy client, not a prompt-injected model. It does mean the gate is a speed bump for a human-in-the-loop client and not a security boundary. See Security and TM-MCP-003.
The prompt also shows a general consequence message, not the arguments of the call. Look at the arguments your client displays.
Don’t auto-approve
Many clients can auto-approve tool calls. Leave the gated tools off any auto-approve list, and treat xianyun_up, xianyun_deploy, and xianyun_update with care too, since they can run hooks from your config.
Workflows
These are loops an agent can run today. Each step is one tool call.
Validate, bring up, check
xianyun_config_validate. A schema error names the field. Fix it before anything else.xianyun_doctor. It reports environment problems it knows about.xianyun_up. This starts the cluster through the configured driver and waits for its API.xianyun_status. Confirms the state.- When you’re done, ask the human to approve
xianyun_down, orxianyun_destroyfor a clean slate.
up and down are idempotent. Calling xianyun_down on a cluster that was never started succeeds, so an agent doesn’t need to check first.
Read results properly
A call can fail at two levels. If the client reports a tool error without running anything, the server refused the call, as it does for a gated tool on an older client. If the call ran and the command failed, the result has a non-zero exit_code and the reason in stderr. Retry logic should look at exit_code, not only at whether the call returned.
xianyun_status prints nothing and exits 0 when no cluster has been started, so an empty result means “stopped”, not “broken”.
Inspect a running environment
xianyun_registry_statuswith{"output": "json"}returns the image cache state as JSON: whether zot is running, its port, and the upstreams.xianyun_logsreturns edit-mode process output. Pass process names inargsand atailflag to bound the output.xianyun_ca_inspectshows the dev CA’s path, subject, and which name constraints it enforces.
Keep repositories current
xianyun_update with {"verify_only": true} reports what would change without touching any repository. Call it before a real update, and read the output: a repository whose fast-forward isn’t possible is reported and left alone.
Limits today
Be honest with your agent about what doesn’t work yet, or it will spend turns on it.
- The
builtin:dev-caandbuiltin:traefik-tlsstoreoverlays haven’t been deployed end to end, though thehelm,kustomize, andnamespacedeployers have. See Known limitations. - Edit mode (
xianyun run) isn’t available over MCP and is only partly implemented in the CLI. - Gated tools need a client that supports elicitation.
Security
This page is the short version for people using the tools. The threat model has the evidence and the scoring.
Use standard input and output
The stdio transport has no network listener. Prefer it.
xianyun-mcp mcp stream listens on 0.0.0.0 when you don’t pass --host, and has no authentication. The only protection is a check of the HTTP Host header, which a script can satisfy by sending Host: localhost. If you must use it, pass --host 127.0.0.1:
$ xianyun-mcp mcp stream --host 127.0.0.1 --port 8080
This was measured: with no --host, the process listened on 0.0.0.0:18099. The default comes from the brontes library underneath, so fixing it takes a wrapper or an upstream change. (TM-MCP-001)
Treat configuration as code
A Xianyun config can run commands on your machine. Lifecycle hooks run programs, and the config can come from places other than the file you wrote:
extends:fragments fetched over HTTPS, with no pinning or signature check.- A
xianyun-project.yamlinside a repository you cloned for edit mode. - A
xianyun.yamlfound by walking up from your working directory.
xianyun_up, xianyun_deploy, and xianyun_update run without a prompt and can trigger those hooks. Read the hooks in a config before your agent runs it, and be careful about letting an agent pull a repository whose project file you haven’t read. (TM-MCP-002, TM-CFG-001, TM-CFG-004)
Secrets in your environment
xianyun_config_print expands ${NAME} references from your whole process environment and prints the result to the agent. A config that references ${AWS_SECRET_ACCESS_KEY} in a field that gets printed puts that secret in the agent’s context. This was measured. (TM-CFG-003)
The confirmation prompt isn’t a boundary
See Confirmation and gated tools. It protects you from an agent that calls a destructive tool by mistake, as long as your client supports it. It doesn’t protect you from a client that skips the prompt.
Shared machines
Several files are created world-readable on a default umask, which matters on a machine other people log into. Measured on 2026-10-13:
- The kubeconfig backup
~/.kube/config.caproni.bakis mode 0644 although the kubeconfig is 0600. - During a Kustomize build, the dev CA’s private key is staged in
$TMPDIRas a 0644 file inside a 0755 directory. This path only runs when a Kustomize deployer does, which can’t happen yet.
Config discovery also keeps walking upward when you’re outside your home directory, so a xianyun.yaml planted in /tmp could be picked up if you run the tool from under it. That one wasn’t measured.
Reporting a problem
Add what you find to the threat model, with the command that shows it.
Troubleshooting
no caproni.yaml found in current directory or any parent directory up to home directory
The server started somewhere without a config above it. The message says caproni.yaml because the string hasn’t been renamed; xianyun.yaml works too. Start xianyun-mcp in your project directory, or set your client’s working-directory option.
middleware asked the client for input, which requires MCP protocol 2026-07-28 or newer
You called a gated tool from a client that negotiated an older protocol, or one that doesn’t declare elicitation. The command didn’t run. Run the matching xianyun command in a terminal.
The tools don’t show up
Run the server yourself and list what it exports:
$ xianyun-mcp mcp tools
$ head mcp-tools.json
If that works, the problem is in your client’s config. Confirm the command is on the PATH your client uses, which can differ from your shell’s.
A command works in a terminal and fails over MCP
The server runs with the environment and working directory your client gave it. Differences in PATH, HOME, KUBECONFIG, or DOCKER_HOST change results. Run xianyun_doctor through MCP and compare it with the terminal.
xianyun_up fails with an unknown deployer type
The type must be helm, kustomize, or namespace. Any other value fails validation, and nix is a placeholder module with no manager behind it.
Output looks wrong
xianyun_ca_inspect prints dates as Some(SystemTime { ... }), and xianyun version says “caproni”. Both are known (GAP-008) and don’t indicate a broken install.
The command line
xianyun does everything the MCP tools do, plus a few commands that only make sense in a terminal. If you drive Xianyun through an agent, this section is for the moments you step in yourself.
| Command | What it does | Over MCP |
|---|---|---|
up, down, destroy, status | Cluster lifecycle | Yes (down and destroy are gated) |
deploy, update | Install deployers, update repositories | Yes |
config validate, print, migrate | Work with the configuration | Yes |
ca trust, untrust, inspect | Dev CA | Yes (trust and untrust are gated) |
update-etc-hosts, set-kube-context | Host and kubeconfig setup | Yes (update-etc-hosts is gated) |
registry status, start, stop | Image cache | Yes |
logs, doctor, version | Inspection | Yes |
run | Edit mode reloaders, until interrupted | No |
kubectl, k9s, stern, tool | Run another program against the cluster | No |
The command reference has the full help text for every command, generated from the binary.
Options every command accepts
| Option | Meaning |
|---|---|
-c, --config-file <PATH> | Use this config file instead of searching upward from the working directory |
--log-format | text or json |
--log-level, --debug | debug, info, warn, or error; --debug is shorthand for --log-level=debug |
--log-color | true or false |
--log-output | stderr, or a file path |
--log-source | Include the source location in log lines |
--no-cache | Meant to disable remote config fragment caching. It’s currently a no-op because the cache isn’t wired in (GAP-005). |
Logs go to standard error, so standard output stays clean for the command’s real result.
Environment variables
| Variable | Effect |
|---|---|
XIANYUN_NO_CACHE | Same as --no-cache. CAPRONI_NO_CACHE is also read. |
KUBECONFIG | Where set-kube-context writes by default, and what xianyun kubectl and friends override for the project’s cluster |
Configuration values can also read environment variables; see Variables.
Files and directories
| Path | Holds |
|---|---|
xianyun.yaml, xianyun.local.yaml, xianyun-project.yaml | Configuration (the caproni.* names also work) |
~/.local/share/xianyun/<profile>/pki/ | The dev CA |
~/.local/share/xianyun/registry/ | The managed registry cache’s config, data, and log |
~/.kube/config.caproni.bak | The backup set-kube-context writes |
<profile> defaults to xianyun. An existing ~/.local/share/caproni is read as a fallback.
Exit codes
0 means success and anything else is an error. Commands return errors as values and only the entry point turns them into a process exit code.
Command reference
This page is generated from the binary. Some help strings still say “Caproni”; see Known limitations.
xianyun
GitLab Kubernetes Development Kit
Usage: xianyun [OPTIONS] <COMMAND>
Commands:
up Start the Caproni cluster and deploy all deployers
down Stop the Caproni cluster
destroy Destroy the Caproni cluster and delete all its data
status Show the status of all services
update Update all repository source code
deploy Deploy or update Helm charts to the cluster
update-etc-hosts Update /etc/hosts file with cluster hostnames
set-kube-context Add the cluster's context to your kubeconfig
run Run reloaders in edit mode until interrupted
config Configuration management commands
version Print version information
ca Manage the Caproni Dev CA certificate
kubectl Run kubectl in the context of the cluster
k9s Browse the cluster with k9s
stern Tail logs from multiple pods with stern
registry Manage caproni's host-side registry cache
logs View edit mode reloader logs
doctor Run built-in diagnostics to help troubleshoot Caproni
tool Run an executable on PATH with access to the cluster
help Print this message or the help of the given subcommand(s)
Options:
-c, --config-file <CONFIG_FILE> Path to configuration file. If omitted, caproni will perform a search through the parent directories for caproni.yaml
--no-cache Disables remote config fragment caching. `XIANYUN_NO_CACHE` is the primary env var; `CAPRONI_NO_CACHE` is still recognized as a compatibility alias (see `hooks::before_hook`, which checks it since `clap`'s `env` attribute only supports one name) [env: XIANYUN_NO_CACHE=]
--log-format <LOG_FORMAT> Log output format (text or json)
--log-level <LOG_LEVEL> Log level (debug, info, warn, error)
--log-color <LOG_COLOR> Enable/disable colored log output [possible values: true, false]
--log-output <LOG_OUTPUT> Where to write logs (stderr, or a file path)
--log-source Include the source location in log lines
--debug Shorthand for --log-level=debug
-h, --help Print help
-V, --version Print version
xianyun up
Start the Caproni cluster and deploy all deployers
Usage: xianyun up [OPTIONS]
Options:
-c, --config-file <CONFIG_FILE> Path to configuration file. If omitted, caproni will perform a search through the parent directories for caproni.yaml
--no-cache Disables remote config fragment caching. `XIANYUN_NO_CACHE` is the primary env var; `CAPRONI_NO_CACHE` is still recognized as a compatibility alias (see `hooks::before_hook`, which checks it since `clap`'s `env` attribute only supports one name) [env: XIANYUN_NO_CACHE=]
--log-format <LOG_FORMAT> Log output format (text or json)
--log-level <LOG_LEVEL> Log level (debug, info, warn, error)
--log-color <LOG_COLOR> Enable/disable colored log output [possible values: true, false]
--log-output <LOG_OUTPUT> Where to write logs (stderr, or a file path)
--log-source Include the source location in log lines
--debug Shorthand for --log-level=debug
-h, --help Print help
xianyun down
Stop the Caproni cluster
Usage: xianyun down [OPTIONS]
Options:
-c, --config-file <CONFIG_FILE> Path to configuration file. If omitted, caproni will perform a search through the parent directories for caproni.yaml
--no-cache Disables remote config fragment caching. `XIANYUN_NO_CACHE` is the primary env var; `CAPRONI_NO_CACHE` is still recognized as a compatibility alias (see `hooks::before_hook`, which checks it since `clap`'s `env` attribute only supports one name) [env: XIANYUN_NO_CACHE=]
--log-format <LOG_FORMAT> Log output format (text or json)
--log-level <LOG_LEVEL> Log level (debug, info, warn, error)
--log-color <LOG_COLOR> Enable/disable colored log output [possible values: true, false]
--log-output <LOG_OUTPUT> Where to write logs (stderr, or a file path)
--log-source Include the source location in log lines
--debug Shorthand for --log-level=debug
-h, --help Print help
xianyun destroy
Destroy the Caproni cluster and delete all its data
Usage: xianyun destroy [OPTIONS]
Options:
-c, --config-file <CONFIG_FILE> Path to configuration file. If omitted, caproni will perform a search through the parent directories for caproni.yaml
--no-cache Disables remote config fragment caching. `XIANYUN_NO_CACHE` is the primary env var; `CAPRONI_NO_CACHE` is still recognized as a compatibility alias (see `hooks::before_hook`, which checks it since `clap`'s `env` attribute only supports one name) [env: XIANYUN_NO_CACHE=]
--log-format <LOG_FORMAT> Log output format (text or json)
--log-level <LOG_LEVEL> Log level (debug, info, warn, error)
--log-color <LOG_COLOR> Enable/disable colored log output [possible values: true, false]
--log-output <LOG_OUTPUT> Where to write logs (stderr, or a file path)
--log-source Include the source location in log lines
--debug Shorthand for --log-level=debug
-h, --help Print help
xianyun status
Show the status of all services
Usage: xianyun status [OPTIONS]
Options:
-c, --config-file <CONFIG_FILE> Path to configuration file. If omitted, caproni will perform a search through the parent directories for caproni.yaml
--no-cache Disables remote config fragment caching. `XIANYUN_NO_CACHE` is the primary env var; `CAPRONI_NO_CACHE` is still recognized as a compatibility alias (see `hooks::before_hook`, which checks it since `clap`'s `env` attribute only supports one name) [env: XIANYUN_NO_CACHE=]
--log-format <LOG_FORMAT> Log output format (text or json)
--log-level <LOG_LEVEL> Log level (debug, info, warn, error)
--log-color <LOG_COLOR> Enable/disable colored log output [possible values: true, false]
--log-output <LOG_OUTPUT> Where to write logs (stderr, or a file path)
--log-source Include the source location in log lines
--debug Shorthand for --log-level=debug
-h, --help Print help
xianyun update
Update all repository source code
Usage: xianyun update [OPTIONS]
Options:
-c, --config-file <CONFIG_FILE> Path to configuration file. If omitted, caproni will perform a search through the parent directories for caproni.yaml
-n, --verify-only Check repository states without making any changes [alias: --dry-run]
--no-cache Disables remote config fragment caching. `XIANYUN_NO_CACHE` is the primary env var; `CAPRONI_NO_CACHE` is still recognized as a compatibility alias (see `hooks::before_hook`, which checks it since `clap`'s `env` attribute only supports one name) [env: XIANYUN_NO_CACHE=]
--stash Automatically stash uncommitted changes before updating
--log-format <LOG_FORMAT> Log output format (text or json)
--repo <REPO> Update only the specified repository
--log-level <LOG_LEVEL> Log level (debug, info, warn, error)
--skip <SKIP> Skip the specified repository
--log-color <LOG_COLOR> Enable/disable colored log output [possible values: true, false]
-q, --quiet Suppress detailed output, only show summary
--log-output <LOG_OUTPUT> Where to write logs (stderr, or a file path)
--log-source Include the source location in log lines
--debug Shorthand for --log-level=debug
-h, --help Print help
xianyun deploy
Deploy or update Helm charts to the cluster
Usage: xianyun deploy [OPTIONS]
Options:
-c, --config-file <CONFIG_FILE> Path to configuration file. If omitted, caproni will perform a search through the parent directories for caproni.yaml
--repo <REPO> Deploy only the specified deployer (must match a key under deployers: in caproni.yaml)
--no-cache Disables remote config fragment caching. `XIANYUN_NO_CACHE` is the primary env var; `CAPRONI_NO_CACHE` is still recognized as a compatibility alias (see `hooks::before_hook`, which checks it since `clap`'s `env` attribute only supports one name) [env: XIANYUN_NO_CACHE=]
--update Update repositories before deploying (pull latest changes for deployer-referenced repos)
--log-format <LOG_FORMAT> Log output format (text or json)
--log-level <LOG_LEVEL> Log level (debug, info, warn, error)
--log-color <LOG_COLOR> Enable/disable colored log output [possible values: true, false]
--log-output <LOG_OUTPUT> Where to write logs (stderr, or a file path)
--log-source Include the source location in log lines
--debug Shorthand for --log-level=debug
-h, --help Print help
xianyun update-etc-hosts
Update /etc/hosts file with cluster hostnames
Usage: xianyun update-etc-hosts [OPTIONS]
Options:
-c, --config-file <CONFIG_FILE> Path to configuration file. If omitted, caproni will perform a search through the parent directories for caproni.yaml
--dry-run Show what would be changed without modifying /etc/hosts
--no-cache Disables remote config fragment caching. `XIANYUN_NO_CACHE` is the primary env var; `CAPRONI_NO_CACHE` is still recognized as a compatibility alias (see `hooks::before_hook`, which checks it since `clap`'s `env` attribute only supports one name) [env: XIANYUN_NO_CACHE=]
--remove Remove hostnames from /etc/hosts instead of adding them
--ip <IP> IP address to use (default: auto-detect from cluster)
--log-format <LOG_FORMAT> Log output format (text or json)
--log-level <LOG_LEVEL> Log level (debug, info, warn, error)
--log-color <LOG_COLOR> Enable/disable colored log output [possible values: true, false]
--log-output <LOG_OUTPUT> Where to write logs (stderr, or a file path)
--log-source Include the source location in log lines
--debug Shorthand for --log-level=debug
-h, --help Print help
xianyun set-kube-context
Add the cluster's context to your kubeconfig
Usage: xianyun set-kube-context [OPTIONS]
Options:
-c, --config-file <CONFIG_FILE> Path to configuration file. If omitted, caproni will perform a search through the parent directories for caproni.yaml
-u, --use Also make the context your current context
--no-cache Disables remote config fragment caching. `XIANYUN_NO_CACHE` is the primary env var; `CAPRONI_NO_CACHE` is still recognized as a compatibility alias (see `hooks::before_hook`, which checks it since `clap`'s `env` attribute only supports one name) [env: XIANYUN_NO_CACHE=]
--remove Remove the context from the kubeconfig instead of adding it
--dry-run Show what would be changed without modifying the kubeconfig
--log-format <LOG_FORMAT> Log output format (text or json)
--kubeconfig <KUBECONFIG> Kubeconfig to modify (default: $KUBECONFIG, or ~/.kube/config)
--log-level <LOG_LEVEL> Log level (debug, info, warn, error)
--log-color <LOG_COLOR> Enable/disable colored log output [possible values: true, false]
--log-output <LOG_OUTPUT> Where to write logs (stderr, or a file path)
--log-source Include the source location in log lines
--debug Shorthand for --log-level=debug
-h, --help Print help
xianyun run
Run reloaders in edit mode until interrupted
Usage: xianyun run [OPTIONS]
Options:
-c, --config-file <CONFIG_FILE> Path to configuration file. If omitted, caproni will perform a search through the parent directories for caproni.yaml
--no-cache Disables remote config fragment caching. `XIANYUN_NO_CACHE` is the primary env var; `CAPRONI_NO_CACHE` is still recognized as a compatibility alias (see `hooks::before_hook`, which checks it since `clap`'s `env` attribute only supports one name) [env: XIANYUN_NO_CACHE=]
--log-format <LOG_FORMAT> Log output format (text or json)
--log-level <LOG_LEVEL> Log level (debug, info, warn, error)
--log-color <LOG_COLOR> Enable/disable colored log output [possible values: true, false]
--log-output <LOG_OUTPUT> Where to write logs (stderr, or a file path)
--log-source Include the source location in log lines
--debug Shorthand for --log-level=debug
-h, --help Print help
xianyun config
Configuration management commands
Usage: xianyun config [OPTIONS] <COMMAND>
Commands:
migrate Migrate configuration to the latest format
validate Validate configuration file
print Print the resolved configuration
help Print this message or the help of the given subcommand(s)
Options:
-c, --config-file <CONFIG_FILE> Path to configuration file. If omitted, caproni will perform a search through the parent directories for caproni.yaml
--no-cache Disables remote config fragment caching. `XIANYUN_NO_CACHE` is the primary env var; `CAPRONI_NO_CACHE` is still recognized as a compatibility alias (see `hooks::before_hook`, which checks it since `clap`'s `env` attribute only supports one name) [env: XIANYUN_NO_CACHE=]
--log-format <LOG_FORMAT> Log output format (text or json)
--log-level <LOG_LEVEL> Log level (debug, info, warn, error)
--log-color <LOG_COLOR> Enable/disable colored log output [possible values: true, false]
--log-output <LOG_OUTPUT> Where to write logs (stderr, or a file path)
--log-source Include the source location in log lines
--debug Shorthand for --log-level=debug
-h, --help Print help
xianyun config migrate
Migrate configuration to the latest format
Usage: xianyun config migrate [OPTIONS]
Options:
-c, --config-file <CONFIG_FILE> Path to configuration file. If omitted, caproni will perform a search through the parent directories for caproni.yaml
-n, --dry-run Preview migration without modifying files
-f, --force Skip confirmation prompt
--no-cache Disables remote config fragment caching. `XIANYUN_NO_CACHE` is the primary env var; `CAPRONI_NO_CACHE` is still recognized as a compatibility alias (see `hooks::before_hook`, which checks it since `clap`'s `env` attribute only supports one name) [env: XIANYUN_NO_CACHE=]
--log-format <LOG_FORMAT> Log output format (text or json)
--skip-backup Skip creating backup file (use with caution)
--log-level <LOG_LEVEL> Log level (debug, info, warn, error)
--log-color <LOG_COLOR> Enable/disable colored log output [possible values: true, false]
--log-output <LOG_OUTPUT> Where to write logs (stderr, or a file path)
--log-source Include the source location in log lines
--debug Shorthand for --log-level=debug
-h, --help Print help
xianyun config validate
Validate configuration file
Usage: xianyun config validate [OPTIONS]
Options:
--allow-deprecated Allow deprecated (older, non-latest) configuration versions
-c, --config-file <CONFIG_FILE> Path to configuration file. If omitted, caproni will perform a search through the parent directories for caproni.yaml
--no-cache Disables remote config fragment caching. `XIANYUN_NO_CACHE` is the primary env var; `CAPRONI_NO_CACHE` is still recognized as a compatibility alias (see `hooks::before_hook`, which checks it since `clap`'s `env` attribute only supports one name) [env: XIANYUN_NO_CACHE=]
--log-format <LOG_FORMAT> Log output format (text or json)
--log-level <LOG_LEVEL> Log level (debug, info, warn, error)
--log-color <LOG_COLOR> Enable/disable colored log output [possible values: true, false]
--log-output <LOG_OUTPUT> Where to write logs (stderr, or a file path)
--log-source Include the source location in log lines
--debug Shorthand for --log-level=debug
-h, --help Print help
xianyun config print
Print the resolved configuration
Usage: xianyun config print [OPTIONS]
Options:
-c, --config-file <CONFIG_FILE> Path to configuration file. If omitted, caproni will perform a search through the parent directories for caproni.yaml
--no-cache Disables remote config fragment caching. `XIANYUN_NO_CACHE` is the primary env var; `CAPRONI_NO_CACHE` is still recognized as a compatibility alias (see `hooks::before_hook`, which checks it since `clap`'s `env` attribute only supports one name) [env: XIANYUN_NO_CACHE=]
--log-format <LOG_FORMAT> Log output format (text or json)
--log-level <LOG_LEVEL> Log level (debug, info, warn, error)
--log-color <LOG_COLOR> Enable/disable colored log output [possible values: true, false]
--log-output <LOG_OUTPUT> Where to write logs (stderr, or a file path)
--log-source Include the source location in log lines
--debug Shorthand for --log-level=debug
-h, --help Print help
xianyun version
Print version information
Usage: xianyun version [OPTIONS]
Options:
-c, --config-file <CONFIG_FILE> Path to configuration file. If omitted, caproni will perform a search through the parent directories for caproni.yaml
--no-cache Disables remote config fragment caching. `XIANYUN_NO_CACHE` is the primary env var; `CAPRONI_NO_CACHE` is still recognized as a compatibility alias (see `hooks::before_hook`, which checks it since `clap`'s `env` attribute only supports one name) [env: XIANYUN_NO_CACHE=]
--log-format <LOG_FORMAT> Log output format (text or json)
--log-level <LOG_LEVEL> Log level (debug, info, warn, error)
--log-color <LOG_COLOR> Enable/disable colored log output [possible values: true, false]
--log-output <LOG_OUTPUT> Where to write logs (stderr, or a file path)
--log-source Include the source location in log lines
--debug Shorthand for --log-level=debug
-h, --help Print help
xianyun ca
Manage the Caproni Dev CA certificate
Usage: xianyun ca [OPTIONS] <COMMAND>
Commands:
trust Install the CA certificate in the system trust store
untrust Remove the CA certificate from the system trust store
inspect Show details about the CA certificate
help Print this message or the help of the given subcommand(s)
Options:
-c, --config-file <CONFIG_FILE> Path to configuration file. If omitted, caproni will perform a search through the parent directories for caproni.yaml
--no-cache Disables remote config fragment caching. `XIANYUN_NO_CACHE` is the primary env var; `CAPRONI_NO_CACHE` is still recognized as a compatibility alias (see `hooks::before_hook`, which checks it since `clap`'s `env` attribute only supports one name) [env: XIANYUN_NO_CACHE=]
--log-format <LOG_FORMAT> Log output format (text or json)
--log-level <LOG_LEVEL> Log level (debug, info, warn, error)
--log-color <LOG_COLOR> Enable/disable colored log output [possible values: true, false]
--log-output <LOG_OUTPUT> Where to write logs (stderr, or a file path)
--log-source Include the source location in log lines
--debug Shorthand for --log-level=debug
-h, --help Print help
xianyun ca trust
Install the CA certificate in the system trust store
Usage: xianyun ca trust [OPTIONS]
Options:
-c, --config-file <CONFIG_FILE> Path to configuration file. If omitted, caproni will perform a search through the parent directories for caproni.yaml
--no-cache Disables remote config fragment caching. `XIANYUN_NO_CACHE` is the primary env var; `CAPRONI_NO_CACHE` is still recognized as a compatibility alias (see `hooks::before_hook`, which checks it since `clap`'s `env` attribute only supports one name) [env: XIANYUN_NO_CACHE=]
--log-format <LOG_FORMAT> Log output format (text or json)
--log-level <LOG_LEVEL> Log level (debug, info, warn, error)
--log-color <LOG_COLOR> Enable/disable colored log output [possible values: true, false]
--log-output <LOG_OUTPUT> Where to write logs (stderr, or a file path)
--log-source Include the source location in log lines
--debug Shorthand for --log-level=debug
-h, --help Print help
xianyun ca untrust
Remove the CA certificate from the system trust store
Usage: xianyun ca untrust [OPTIONS]
Options:
-c, --config-file <CONFIG_FILE> Path to configuration file. If omitted, caproni will perform a search through the parent directories for caproni.yaml
--no-cache Disables remote config fragment caching. `XIANYUN_NO_CACHE` is the primary env var; `CAPRONI_NO_CACHE` is still recognized as a compatibility alias (see `hooks::before_hook`, which checks it since `clap`'s `env` attribute only supports one name) [env: XIANYUN_NO_CACHE=]
--log-format <LOG_FORMAT> Log output format (text or json)
--log-level <LOG_LEVEL> Log level (debug, info, warn, error)
--log-color <LOG_COLOR> Enable/disable colored log output [possible values: true, false]
--log-output <LOG_OUTPUT> Where to write logs (stderr, or a file path)
--log-source Include the source location in log lines
--debug Shorthand for --log-level=debug
-h, --help Print help
xianyun ca inspect
Show details about the CA certificate
Usage: xianyun ca inspect [OPTIONS]
Options:
-c, --config-file <CONFIG_FILE> Path to configuration file. If omitted, caproni will perform a search through the parent directories for caproni.yaml
--no-cache Disables remote config fragment caching. `XIANYUN_NO_CACHE` is the primary env var; `CAPRONI_NO_CACHE` is still recognized as a compatibility alias (see `hooks::before_hook`, which checks it since `clap`'s `env` attribute only supports one name) [env: XIANYUN_NO_CACHE=]
--log-format <LOG_FORMAT> Log output format (text or json)
--log-level <LOG_LEVEL> Log level (debug, info, warn, error)
--log-color <LOG_COLOR> Enable/disable colored log output [possible values: true, false]
--log-output <LOG_OUTPUT> Where to write logs (stderr, or a file path)
--log-source Include the source location in log lines
--debug Shorthand for --log-level=debug
-h, --help Print help
xianyun kubectl
Run kubectl in the context of the cluster
Usage: xianyun kubectl [OPTIONS] [ARGS]...
Arguments:
[ARGS]... Arguments forwarded verbatim to the `kubectl` executable
Options:
-c, --config-file <CONFIG_FILE> Path to configuration file. If omitted, caproni will perform a search through the parent directories for caproni.yaml
--no-cache Disables remote config fragment caching. `XIANYUN_NO_CACHE` is the primary env var; `CAPRONI_NO_CACHE` is still recognized as a compatibility alias (see `hooks::before_hook`, which checks it since `clap`'s `env` attribute only supports one name) [env: XIANYUN_NO_CACHE=]
--log-format <LOG_FORMAT> Log output format (text or json)
--log-level <LOG_LEVEL> Log level (debug, info, warn, error)
--log-color <LOG_COLOR> Enable/disable colored log output [possible values: true, false]
--log-output <LOG_OUTPUT> Where to write logs (stderr, or a file path)
--log-source Include the source location in log lines
--debug Shorthand for --log-level=debug
xianyun k9s
Browse the cluster with k9s
Usage: xianyun k9s [OPTIONS] [ARGS]...
Arguments:
[ARGS]... Arguments forwarded verbatim to the `k9s` executable
Options:
-c, --config-file <CONFIG_FILE> Path to configuration file. If omitted, caproni will perform a search through the parent directories for caproni.yaml
--no-cache Disables remote config fragment caching. `XIANYUN_NO_CACHE` is the primary env var; `CAPRONI_NO_CACHE` is still recognized as a compatibility alias (see `hooks::before_hook`, which checks it since `clap`'s `env` attribute only supports one name) [env: XIANYUN_NO_CACHE=]
--log-format <LOG_FORMAT> Log output format (text or json)
--log-level <LOG_LEVEL> Log level (debug, info, warn, error)
--log-color <LOG_COLOR> Enable/disable colored log output [possible values: true, false]
--log-output <LOG_OUTPUT> Where to write logs (stderr, or a file path)
--log-source Include the source location in log lines
--debug Shorthand for --log-level=debug
xianyun stern
Tail logs from multiple pods with stern
Usage: xianyun stern [OPTIONS] [ARGS]...
Arguments:
[ARGS]... Arguments forwarded verbatim to the `stern` executable
Options:
-c, --config-file <CONFIG_FILE> Path to configuration file. If omitted, caproni will perform a search through the parent directories for caproni.yaml
--no-cache Disables remote config fragment caching. `XIANYUN_NO_CACHE` is the primary env var; `CAPRONI_NO_CACHE` is still recognized as a compatibility alias (see `hooks::before_hook`, which checks it since `clap`'s `env` attribute only supports one name) [env: XIANYUN_NO_CACHE=]
--log-format <LOG_FORMAT> Log output format (text or json)
--log-level <LOG_LEVEL> Log level (debug, info, warn, error)
--log-color <LOG_COLOR> Enable/disable colored log output [possible values: true, false]
--log-output <LOG_OUTPUT> Where to write logs (stderr, or a file path)
--log-source Include the source location in log lines
--debug Shorthand for --log-level=debug
xianyun registry
Manage caproni's host-side registry cache
Usage: xianyun registry [OPTIONS] <COMMAND>
Commands:
status Show the host-side registry cache state
start Start the host-side registry cache (zot process)
stop Stop the host-side registry cache (zot process)
help Print this message or the help of the given subcommand(s)
Options:
-c, --config-file <CONFIG_FILE> Path to configuration file. If omitted, caproni will perform a search through the parent directories for caproni.yaml
--no-cache Disables remote config fragment caching. `XIANYUN_NO_CACHE` is the primary env var; `CAPRONI_NO_CACHE` is still recognized as a compatibility alias (see `hooks::before_hook`, which checks it since `clap`'s `env` attribute only supports one name) [env: XIANYUN_NO_CACHE=]
--log-format <LOG_FORMAT> Log output format (text or json)
--log-level <LOG_LEVEL> Log level (debug, info, warn, error)
--log-color <LOG_COLOR> Enable/disable colored log output [possible values: true, false]
--log-output <LOG_OUTPUT> Where to write logs (stderr, or a file path)
--log-source Include the source location in log lines
--debug Shorthand for --log-level=debug
-h, --help Print help
xianyun registry status
Show the host-side registry cache state
Usage: xianyun registry status [OPTIONS]
Options:
-c, --config-file <CONFIG_FILE> Path to configuration file. If omitted, caproni will perform a search through the parent directories for caproni.yaml
-o, --output <OUTPUT> Output format: text or json [default: text] [possible values: text, json]
--no-cache Disables remote config fragment caching. `XIANYUN_NO_CACHE` is the primary env var; `CAPRONI_NO_CACHE` is still recognized as a compatibility alias (see `hooks::before_hook`, which checks it since `clap`'s `env` attribute only supports one name) [env: XIANYUN_NO_CACHE=]
--log-format <LOG_FORMAT> Log output format (text or json)
--log-level <LOG_LEVEL> Log level (debug, info, warn, error)
--log-color <LOG_COLOR> Enable/disable colored log output [possible values: true, false]
--log-output <LOG_OUTPUT> Where to write logs (stderr, or a file path)
--log-source Include the source location in log lines
--debug Shorthand for --log-level=debug
-h, --help Print help
xianyun registry start
Start the host-side registry cache (zot process)
Usage: xianyun registry start [OPTIONS]
Options:
-c, --config-file <CONFIG_FILE> Path to configuration file. If omitted, caproni will perform a search through the parent directories for caproni.yaml
--no-cache Disables remote config fragment caching. `XIANYUN_NO_CACHE` is the primary env var; `CAPRONI_NO_CACHE` is still recognized as a compatibility alias (see `hooks::before_hook`, which checks it since `clap`'s `env` attribute only supports one name) [env: XIANYUN_NO_CACHE=]
--log-format <LOG_FORMAT> Log output format (text or json)
--log-level <LOG_LEVEL> Log level (debug, info, warn, error)
--log-color <LOG_COLOR> Enable/disable colored log output [possible values: true, false]
--log-output <LOG_OUTPUT> Where to write logs (stderr, or a file path)
--log-source Include the source location in log lines
--debug Shorthand for --log-level=debug
-h, --help Print help
xianyun registry stop
Stop the host-side registry cache (zot process)
Usage: xianyun registry stop [OPTIONS]
Options:
-c, --config-file <CONFIG_FILE> Path to configuration file. If omitted, caproni will perform a search through the parent directories for caproni.yaml
--no-cache Disables remote config fragment caching. `XIANYUN_NO_CACHE` is the primary env var; `CAPRONI_NO_CACHE` is still recognized as a compatibility alias (see `hooks::before_hook`, which checks it since `clap`'s `env` attribute only supports one name) [env: XIANYUN_NO_CACHE=]
--log-format <LOG_FORMAT> Log output format (text or json)
--log-level <LOG_LEVEL> Log level (debug, info, warn, error)
--log-color <LOG_COLOR> Enable/disable colored log output [possible values: true, false]
--log-output <LOG_OUTPUT> Where to write logs (stderr, or a file path)
--log-source Include the source location in log lines
--debug Shorthand for --log-level=debug
-h, --help Print help
xianyun logs
View edit mode reloader logs
Usage: xianyun logs [OPTIONS] [PROC_NAMES]...
Arguments:
[PROC_NAMES]... Process names to filter logs to
Options:
-c, --config-file <CONFIG_FILE> Path to configuration file. If omitted, caproni will perform a search through the parent directories for caproni.yaml
-n, --tail <TAIL> Number of lines to show from the end of the logs [default: 25]
--no-cache Disables remote config fragment caching. `XIANYUN_NO_CACHE` is the primary env var; `CAPRONI_NO_CACHE` is still recognized as a compatibility alias (see `hooks::before_hook`, which checks it since `clap`'s `env` attribute only supports one name) [env: XIANYUN_NO_CACHE=]
--no-log-prefix Don't print prefix in logs
--log-format <LOG_FORMAT> Log output format (text or json)
--log-level <LOG_LEVEL> Log level (debug, info, warn, error)
--log-color <LOG_COLOR> Enable/disable colored log output [possible values: true, false]
--log-output <LOG_OUTPUT> Where to write logs (stderr, or a file path)
--log-source Include the source location in log lines
--debug Shorthand for --log-level=debug
-h, --help Print help
xianyun doctor
Run built-in diagnostics to help troubleshoot Caproni
Usage: xianyun doctor [OPTIONS]
Options:
-c, --config-file <CONFIG_FILE> Path to configuration file. If omitted, caproni will perform a search through the parent directories for caproni.yaml
--no-cache Disables remote config fragment caching. `XIANYUN_NO_CACHE` is the primary env var; `CAPRONI_NO_CACHE` is still recognized as a compatibility alias (see `hooks::before_hook`, which checks it since `clap`'s `env` attribute only supports one name) [env: XIANYUN_NO_CACHE=]
--log-format <LOG_FORMAT> Log output format (text or json)
--log-level <LOG_LEVEL> Log level (debug, info, warn, error)
--log-color <LOG_COLOR> Enable/disable colored log output [possible values: true, false]
--log-output <LOG_OUTPUT> Where to write logs (stderr, or a file path)
--log-source Include the source location in log lines
--debug Shorthand for --log-level=debug
-h, --help Print help
xianyun tool
Run an executable on PATH with access to the cluster
Usage: xianyun tool [OPTIONS] [ARGS]...
Arguments:
[ARGS]... The tool to run, followed by its arguments
Options:
-c, --config-file <CONFIG_FILE> Path to configuration file. If omitted, caproni will perform a search through the parent directories for caproni.yaml
--no-cache Disables remote config fragment caching. `XIANYUN_NO_CACHE` is the primary env var; `CAPRONI_NO_CACHE` is still recognized as a compatibility alias (see `hooks::before_hook`, which checks it since `clap`'s `env` attribute only supports one name) [env: XIANYUN_NO_CACHE=]
--log-format <LOG_FORMAT> Log output format (text or json)
--log-level <LOG_LEVEL> Log level (debug, info, warn, error)
--log-color <LOG_COLOR> Enable/disable colored log output [possible values: true, false]
--log-output <LOG_OUTPUT> Where to write logs (stderr, or a file path)
--log-source Include the source location in log lines
--debug Shorthand for --log-level=debug
Configuration overview
A project is described by one YAML file, xianyun.yaml. If you have caproni.yaml, Xianyun reads that instead and you don’t need to change anything. When both exist in one directory, xianyun.yaml wins.
Minimal file
version: 2
cluster:
driver: auto
network:
hostname: myapp.test
version must be 2, and cluster with its network block is required. Everything else is optional.
The sections
| Key | Purpose |
|---|---|
cluster | Which driver to use, the cluster’s hostnames, driver settings, and the image cache. Reference |
repositories | Git repositories Xianyun clones and updates |
deployers | What to install into the cluster: Helm charts, Kustomize overlays, namespaces |
reloaders | Edit-mode processes with hot reload, through mirrord or Skaffold |
variables | Named values you reference elsewhere. Variables |
extends | Other config fragments to merge in, by path or HTTPS URL |
How files combine
Xianyun builds the final configuration from several sources and deep-merges them, with later ones overriding earlier ones:
- The base file,
xianyun.yaml. - Any
extends:fragments, left to right. xianyun.local.yamlnext to the base file, for settings that are only yours.- A
xianyun-project.yamlfrom each repository withedit_mode: true, which can add reloaders and lifecycle hooks.
List values for lifecycle_hooks, additional_hostnames, and dynamic_overrides append instead of replacing.
Because several of those sources can come from outside your own file, read Security before you add a remote extends or an edit-mode repository you don’t control.
Composition is covered on its own page: Composing configuration.
Check what you wrote
$ xianyun config validate # check it against the schema
$ xianyun config print # show the fully merged, expanded result
config print is the quickest way to see what a variable or an extends produced. It also prints expanded environment values, so don’t paste its output somewhere public if your config references secrets.
The schema is schema/config.v2.schema.json in the repository. Editors that understand JSON Schema can use it for completion if you add this to the top of your file:
# yaml-language-server: $schema=../schema/config.v2.schema.json
Examples
The repository’s examples/ directory has working files. These all pass xianyun config validate:
cluster-only.yaml, the smallest useful configuration.namespace-deployer.yaml,kustomize-deployer.yaml,local-chart.yaml, andoci-chart.yaml, one per deployer style.simple-helm-chart-k3d.yaml, a Helm chart plus repositories and an edit-mode reloader.external.yaml, which adopts an existing cluster.variables.yaml, which shows variable definitions and file-typed variables.
Configuration reference
This page summarises the keys. The authoritative description is the schema, schema/config.v2.schema.json, and xianyun config validate enforces it.
Top level
| Key | Type | Required |
|---|---|---|
version | integer, must be 2 | yes |
cluster | object | yes |
extends | list of paths or HTTPS URLs | no |
repositories | map of name to repository | no |
deployers | map of name to deployer | no |
reloaders | map of name to reloader | no |
variables | map of name to value | no |
cluster
| Key | Meaning |
|---|---|
driver | auto, colima, k3d, external, vcluster, or mock. auto picks k3d on Linux and colima elsewhere. mock is for tests. |
name.strategy | static (default, uses xianyun) or directory-hash |
network.hostname | The primary hostname, for example gitlab.test. Required. |
network.additional_hostnames | More hostnames to set up, for example a registry |
network.cluster_domain | The cluster DNS domain, when it isn’t under .local |
registry_mirror | The pull-through image cache. See below. |
driver and k3s.disable can’t contain variables. They must be written literally.
Driver settings
| Block | Keys |
|---|---|
k3d | agents, servers, image, port_mappings, k3s.disable, extra_args (passed verbatim to k3d cluster create, one token per entry) |
colima | memory, cores, disk, k3s.disable. Colima always runs on the containerd runtime. |
external | context, the kubeconfig context to use (the current one if omitted). down and destroy return an error because Xianyun doesn’t own the cluster. |
vcluster | host_context (required), namespace, chart_version, values, manage_namespace |
mock | No settings |
registry_mirror
| Key | Meaning |
|---|---|
managed | true runs and supervises a host-side zot cache. Default false. |
endpoint | URL of a cache you run yourself, reachable from the cluster nodes. Required when managed is false, and not allowed when it’s true. |
upstreams | Registry hostnames whose pulls go through the cache, such as docker.io. The feature is off when this is empty. |
See Registry cache.
repositories
Each entry needs repository (a Git URL) and directory (where to clone it). Optional keys: ref (a branch, tag, or commit; a tag or SHA pins the repository), edit_mode, and lifecycle_hooks.
deployers
Each deployer has a type, an optional needs list naming deployers that must finish first, and an optional repository. Deployers without a needs edge run in no defined order.
| Type | Body | Notes |
|---|---|---|
helm | helm.namespace (required), wait, timeout, values_file, dynamic_overrides, repository, release | Charts can be local, OCI, or from a chart repository |
kustomize | kustomize.path (required), namespace, files | builtin:dev-ca and builtin:traefik-tlsstore are built-in targets |
namespace | namespace.namespaces (list of names, required), delete_on_undeploy | Creates namespaces and waits for each to be usable |
The builtin: overlays haven’t been deployed end to end; see Known limitations.
reloaders
| Type | Body |
|---|---|
mirror | mirror.target (namespace, kubernetes_type, name), mirror.edit_mode_processes (required), filesystem, network |
skaffold | skaffold settings and repository |
kubernetes_type is one of deployment, statefulset, daemonset, or pod. Each edit_mode_processes entry has a command, and optionally a port and a context of mirrord or local. See Edit mode.
lifecycle_hooks
A hook has a type and one of script, mirror_script, or job. The types are edit_mode_prepare, edit_mode_start, and edit_mode_stop.
A script is split into arguments with quote handling and run directly, with no shell, so $HOME, pipes, and ; mean nothing to Xianyun. Wrap the command in sh -c '...' if you need shell behaviour. Hooks inherit your environment plus the predefined variables and KUBECONFIG.
Variables
Configuration values can refer to variables with $NAME or ${NAME}.
Where values come from
A reference is resolved from these places, highest precedence first:
- The
variables:block in the config. - The predefined variables below.
- Your process environment.
The environment fallback is broad. Any variable your shell exports can be read by a config, including a remote fragment you extend. See Security.
Defining variables
Defaults and required checks belong in the variables: block:
variables:
DOMAIN: ${DOMAIN:-example.test} # the environment's DOMAIN, or example.test
SHELL_VERSION:
type: file # read the value from a file
path: ./config/GITLAB_SHELL_VERSION
cluster:
network:
hostname: gitlab.${DOMAIN}
With DOMAIN unset, xianyun config print shows gitlab.example.test. With DOMAIN=lab.test exported, it shows gitlab.lab.test. A file-typed variable reads its value from the named path, relative to the config file or absolute.
Inside a variables: definition you can use ${NAME:-default}, ${NAME:?message}, and ${NAME:+alternate}. Anywhere else those forms are rejected as unsupported expansions. Plain references also support length (${#NAME}), indirection (${!NAME}), slices, replacement, and prefix and suffix removal.
Predefined variables
| Variable | Value |
|---|---|
XIANYUN_CLUSTER_NAME | The cluster profile name (xianyun by default) |
XIANYUN_PRIMARY_HOSTNAME | cluster.network.hostname |
XIANYUN_CLUSTER_DOMAIN | The cluster DNS domain |
CAPRONI_CLUSTER_NAME, CAPRONI_PRIMARY_HOSTNAME, and CAPRONI_CLUSTER_DOMAIN still work and mean the same thing.
What never runs
$(command), backticks, and $((arithmetic)) are left exactly as written. Xianyun doesn’t run a command from configuration or compute one. That’s deliberate, and it matters in fields like script where those constructs belong to the shell that eventually runs the value.
Escape a dollar sign with a backslash to keep it from being treated as a reference: \$HOME stays the text $HOME.
Fields that must be literal
cluster.driver and k3s.disable can’t contain $. A repository ref can’t contain an unresolved variable. Xianyun rejects the config rather than guess.
Composing configuration
You can keep a shared base configuration in version control and layer personal or optional pieces on top of it. Two mechanisms do this: a local override file, and extends: fragments.
Run xianyun config print at any point to see the fully merged result. Everything on this page was checked against that output.
Local overrides
Put xianyun.local.yaml next to xianyun.yaml (or caproni.local.yaml next to caproni.yaml) for settings that are only yours. Xianyun loads it automatically, resolves it relative to the base file, and so it works when you pass -c /path/to/xianyun.yaml. Add it to .gitignore.
# xianyun.local.yaml, not committed
cluster:
colima:
memory: 16GiB
cores: 8
Merge rules
| Value | Behaviour |
|---|---|
| Maps | Merged recursively; overlay keys add to or replace base keys |
| Scalars | The overlay wins |
null | Removes the key, so the default applies |
lifecycle_hooks, cluster.network.additional_hostnames, dynamic_overrides | Lists are appended |
| Any other list | The overlay replaces the base list |
You only write what you want to change. For example, with k3d.agents: 4 in the base file, a local file that sets agents: 7 gives 7, and one that sets agents: null gives 0, the default.
Appended lists keep a fixed order: fragments first, then the base file, then the local file. With a.frag.test in a fragment, a.base.test in the base file, and a.local.test in the local file, the merged additional_hostnames came out in that order.
Shared fragments with extends:
extends: pulls other files into the base. They’re merged left to right, and the base file wins over every fragment. In a test, a fragment that set hostname: frag.test and k3d.servers: 3 lost to a base file with hostname: base.test and servers: 1.
# xianyun.yaml
version: 2
extends:
- xianyun.shared.yaml
- services/xianyun.valkey.yaml
cluster:
driver: k3d
network:
hostname: myapp.test
A fragment holds only the keys it adds or overrides, such as another repository, deployer, or reloader. Name fragments xianyun.<name>.yaml (caproni.<name>.yaml is accepted too). Paths in extends: are relative to the file that lists them, so a fragment can live in a subdirectory next to its supporting files.
A fragment can’t contain extends: itself. Xianyun stops with fragment files must not contain extends:.
xianyun.local.yaml can also use extends: to pull in fragments you don’t commit.
Fragments over HTTPS
An entry in extends: can be an https:// URL whose file name matches xianyun*.yaml or caproni*.yaml. Relative paths inside that fragment, such as a Helm values file, are fetched from the fragment’s location, not from your project. Directories can’t be fetched, so a remote fragment can’t point a Kustomize deployer at a local directory.
Remote fragments carry real risk. They have no pinning, checksum, or signature, and they can add repositories, deployers, and lifecycle hooks that run commands. Read Security before you extend one you don’t control, and prefer a fragment kept in your own repository.
What Xianyun adds on its own
Nothing. Every deployer in the resolved configuration comes from your file, a fragment you extended, your local file, or a xianyun-project.yaml in an edit-mode repository. Two Kustomize overlays are built into the binary but still need a deployer to use them: path: builtin:dev-ca and path: builtin:traefik-tlsstore. See the reference.
The builtin: overlays haven’t been deployed end to end; see Known limitations.
Caproni compatibility
Xianyun reads existing Caproni projects without changes.
| Caproni name | Xianyun name | Behaviour |
|---|---|---|
caproni.yaml | xianyun.yaml | Either works. If both are in one directory, xianyun.yaml wins. |
caproni.local.yaml | xianyun.local.yaml | Same rule |
caproni-project.yaml | xianyun-project.yaml | Same rule |
extends: files named caproni.<name>.yaml | xianyun.<name>.yaml | Both are accepted |
CAPRONI_CLUSTER_NAME and two more | XIANYUN_CLUSTER_NAME and two more | Same values |
CAPRONI_NO_CACHE | XIANYUN_NO_CACHE | Both are read |
~/.local/share/caproni | ~/.local/share/xianyun | The old location is read as a fallback |
What changed, with no alias
Names inside the cluster are new. The cert-manager ClusterIssuer and its Secret are called xianyun-ca, not caproni-ca, and the default profile name is xianyun. If your manifests or flags reference caproni-ca, update them. The repository’s examples/own-infrastructure.yaml still passes --default-issuer-name=caproni-ca and needs the same change.
Still says Caproni
A few strings weren’t renamed: the CA’s common name is “Caproni Dev CA”, the kubeconfig backup ends in .caproni.bak, and parts of --help and xianyun version say Caproni. Two code paths (the k3d and colima drivers) still build the old .local/share/caproni path, so state can land in either directory. These are listed in Known limitations.
Why the split was made is in ADR-0003.
Trusting the dev CA
Browsers and tools reject the certificates your local cluster serves unless they trust the authority that signed them. Xianyun generates a certificate authority (CA) for the project and can install it in your trust stores.
$ xianyun ca inspect # show the CA, creating it on first use
$ xianyun ca trust # install it (asks for sudo)
$ xianyun ca untrust # remove it
Over MCP, ca trust and ca untrust are gated. ca inspect isn’t, and it creates the CA’s key pair the first time it runs.
What gets created
An ECDSA P-256 key pair, valid for ten years, stored per profile under ~/.local/share/xianyun/<profile>/pki/. The files ca.key and ca.crt live in a gen-* directory, and a current symlink points at the live one. Xianyun swaps the symlink atomically, so a reader never sees half a CA.
When the hostnames or cluster domain in your config change, Xianyun re-signs the CA with the same key. If the CA has expired it makes a new one.
$ xianyun ca inspect
Caproni Dev CA
Path: ~/.local/share/xianyun/xianyun/pki/current/ca.crt
Subject: Caproni Dev CA
...
Constraints: [.local .test .svc]
The dates print as raw SystemTime values today. That’s a known display bug.
What it’s allowed to sign
The CA has Name Constraints, so a client that honours them will only accept certificates from this CA for the permitted names. The permitted names are always .local, .test, and .svc, plus your configured hostname and cluster domain when they fall outside those. A hostname like gitlab.dev gets added as its own entry.
The defaults cover every name under those three domains, not only your project’s, and whether a given client enforces Name Constraints varies and hasn’t been tested here. Don’t treat the constraint as a guarantee.
Where it’s installed
| Platform | Location |
|---|---|
| Linux | The system anchor directory, then update-ca-certificates or update-ca-trust |
| macOS | The System keychain |
| Firefox and other NSS databases | Through certutil, if it’s installed. A failure here is reported as a note and doesn’t fail the command. |
untrust also removes the entries older Caproni versions installed.
Where the key goes
A Kustomize deployer using builtin:dev-ca puts the CA’s certificate and key into the cluster as a Secret so cert-manager can issue certificates from it. Anyone who can read Secrets in the cluster can read the key. For a local development cluster that’s usually acceptable, and the Name Constraints are what limit the damage. That path can’t run today, because deployers can’t run yet. See Known limitations and TM-PKI-002.
Registry cache
Recreating a cluster pulls every image again. A pull-through cache on your host keeps them across destroy and up, and cuts down on registry rate limits.
The cache is off unless cluster.registry_mirror lists upstreams.
Two ways to run it
Managed. Xianyun starts and supervises a zot process for you. It needs zot on your PATH.
cluster:
registry_mirror:
managed: true
upstreams:
- docker.io
- quay.io
Bring your own. You run a cache (zot, distribution, Harbor) and Xianyun only writes the mirror settings the cluster needs. It never touches your cache process.
cluster:
registry_mirror:
endpoint: http://host.k3d.internal:5000
upstreams:
- docker.io
managed: true and endpoint are mutually exclusive. driver: external can’t use a mirror, because Xianyun can’t change an unmanaged cluster’s container runtime.
Lifecycle of the managed cache
upstarts zot if it isn’t running.destroyreleases the project’s claim on it but leaves it running, so the nextupis warm.- Only
xianyun registry stopstops it. - One zot serves every profile, with reference counting.
$ xianyun registry status -o json
$ xianyun registry start
$ xianyun registry stop
registry status with -o json returns whether it’s running, its PID and port, the claims on it, its upstreams, the data size, and the paths of its config, data, and log. Over MCP, pass {"output": "json"}.
Security
The managed zot listens on 0.0.0.0:5555 and its generated config has no access control, and the k3d mirror config skips TLS verification. On a shared network, another host may be able to reach the cache. Whether anonymous writes are accepted hasn’t been measured. See TM-REG-001.
The reasoning for the design is in ADR-0007.
Edit mode
Edit mode runs your local code against the cluster, so a change takes effect without rebuilding a container. A reloader in the config says how: mirrord runs your process locally with a pod’s network and filesystem, and Skaffold covers cases mirrord can’t, such as programs that use mmap.
$ xianyun run # run every reloader until you interrupt it
$ xianyun logs # read what the processes printed
This is the least finished part of Xianyun. run starts the reloaders concurrently and waits for all of them before it returns, handles Ctrl-C and SIGTERM, and runs stop hooks. The pieces that run your edit_mode_processes under mirrord aren’t implemented: the internal process supervisor stops after its preflight checks, so a mirror reloader’s processes can’t start. xianyun status also returns an error when the cluster is Ready and reloaders are configured. See Known limitations.
run isn’t available over MCP. It runs until interrupted, which doesn’t fit a single request and response. xianyun_logs is available, and returns the last lines of the reloaders’ logs.
A reloader
reloaders:
gitaly:
type: mirror
repository: gitaly
edit_mode: true
mirror:
target:
namespace: gitlab
kubernetes_type: pod
name: gitlab-gitaly-0
edit_mode_processes:
gitaly:
command: _build/bin/gitaly /etc/gitaly/config.toml
examples/simple-helm-chart-k3d.yaml has a complete file. The repository must be listed under repositories with edit_mode: true, so xianyun update clones it.
Hooks
lifecycle_hooks run commands around edit mode: edit_mode_prepare during up, and edit_mode_start and edit_mode_stop during run. They run with no shell and your full environment. Read the configuration reference and Security before you add one from a source you don’t control.
Lifecycle hooks
Hooks run your own commands at points in the edit-mode lifecycle. Typical uses are installing local dependencies, or fetching a value from the cluster, such as a database password, so a local process can connect to it.
Events
| Type | Runs |
|---|---|
edit_mode_prepare | After a repository is cloned or updated (xianyun up runs these) |
edit_mode_start | Before an edit-mode process starts (xianyun run) |
edit_mode_stop | After an edit-mode process exits (xianyun run) |
Where to put them
Hooks go on a repository, or inside a mirror reloader:
repositories:
myapp:
repository: https://example.com/myapp.git
directory: ./myapp/
lifecycle_hooks:
- type: edit_mode_prepare
script: make deps
- type: edit_mode_start
script: sh -c 'kubectl get secret myapp-db -o jsonpath={.data.password} | base64 -d > .env.db'
Each hook has a type and one of script, mirror_script, or job.
How a script runs
Xianyun splits script into words, honouring single and double quotes, and runs the result directly in the repository’s directory. There’s no shell. $HOME, pipes, redirects, and ; mean nothing to Xianyun, so wrap the command in sh -c '...' when you want them, as the second hook above does. An unterminated quote stops Xianyun before it runs anything.
Variables of the form ${NAME} in a config value are expanded when the config loads, before the script is split. $(...) and backticks are never evaluated by Xianyun; they pass through to whatever shell you invoke. Escape a dollar sign with a backslash to keep Xianyun from expanding it. See Variables.
Environment
A hook gets your whole process environment plus the predefined variables (XIANYUN_CLUSTER_NAME and the rest). When the cluster is available it also gets KUBECONFIG, pointing at a temporary kubeconfig for the project’s cluster, and KUBECACHEDIR, so kubectl and helm inside the hook reach the right cluster. edit_mode_prepare hooks run before the cluster is available, so they run without those two.
Security
A hook is a command that runs as you, with your environment, and it can arrive through extends: fragments or a xianyun-project.yaml in a repository you cloned. Read Security before you run a config you didn’t write. Over MCP, xianyun_up, xianyun_deploy, and xianyun_update can trigger hooks without a confirmation prompt.
Hostnames and kube context
Two commands set up your machine so the cluster is reachable by name.
/etc/hosts
$ xianyun update-etc-hosts --dry-run # show what would change
$ xianyun update-etc-hosts # write it
$ xianyun update-etc-hosts --remove # take the entries out again
Xianyun adds your configured hostname and additional_hostnames, pointing at the cluster’s IP. It only writes when a name is missing or points somewhere else. Writing needs sudo unless you’re root, and it replaces the whole file by piping the new content to sudo tee /etc/hosts.
Without --ip, the cluster’s address is detected, so the cluster has to be running. With --ip, the value is used as given. Pass an IP address and nothing else: the value isn’t validated, and a value containing a newline adds extra lines to the file. See TM-HOSTS-001.
Over MCP this tool is gated, including with dry_run set.
Kube context
$ xianyun set-kube-context --dry-run
$ xianyun set-kube-context --use # also make it the current context
$ xianyun set-kube-context --remove
This merges the project’s cluster into your kubeconfig ($KUBECONFIG or ~/.kube/config, or the file named by --kubeconfig), keeping everything else in it. It writes through a temporary file and renames it, and leaves a backup beside the original with a .caproni.bak suffix. The backup is mode 0644 even though the kubeconfig is 0600, which matters on shared machines. See TM-KUBE-001.
You don’t need this to work with the cluster. xianyun kubectl, xianyun k9s, and xianyun stern all point at the project’s cluster without touching your kubeconfig.
Linux and Podman setup
On Linux, driver: auto selects k3d, which needs a container runtime. Docker works without extra setup. Fedora, RHEL, and CentOS Stream ship Podman instead, and rootless Podman needs four changes before xianyun up can start a cluster.
These steps come from the Caproni project’s Linux guide, which this book replaces. They concern Podman, systemd, and k3d, not Xianyun’s own code, and I didn’t re-run them on a Fedora host. Treat them as a starting point and check them against Podman’s own troubleshooting guide.
1. Enable the Podman socket
k3d talks to the container runtime through a socket:
$ systemctl --user enable --now podman.socket
The socket appears at /run/user/<UID>/podman/podman.sock.
2. Point the Docker CLI at it
Either create a Docker context, which leaves default alone:
$ docker context create podman --description "rootless podman" \
--docker "host=unix:///run/user/$(id -u)/podman/podman.sock"
$ docker context use podman
or repoint the podman-docker package’s symlink, which targets the rootful socket by default. This can need repeating after system updates:
$ sudo ln -sf /run/user/$(id -u)/podman/podman.sock /var/run/docker.sock
3. Delegate the cpuset cgroup controller
k3s needs cpuset, which user sessions don’t get by default. Check:
$ cat /sys/fs/cgroup/user.slice/user-$(id -u).slice/cgroup.controllers
If cpuset is missing, add a systemd override, then log out and back in:
$ sudo mkdir -p /etc/systemd/system/user@.service.d/
$ sudo tee /etc/systemd/system/user@.service.d/delegate.conf <<'EOF'
[Service]
Delegate=cpuset cpu io memory pids
EOF
$ sudo systemctl daemon-reload
This changes which cgroup controllers user sessions can manage. It doesn’t give user processes root privileges.
4. Use unprivileged ports
Rootless Podman can’t bind ports below 1024, so map the load balancer to higher ones:
cluster:
driver: k3d
k3d:
port_mappings:
- "8080:80@loadbalancer"
- "8443:443@loadbalancer"
Services are then at http://localhost:8080. xianyun config validate accepts this block, and config print shows the mappings unchanged.
Check it
$ systemctl --user status podman.socket
$ docker ps
$ xianyun doctor
$ xianyun up
Troubleshooting and resets
For problems with the MCP server itself, see MCP troubleshooting. This page is about the cluster.
Get more detail
Every command takes --log-level debug, or --debug for short. Logs go to standard error:
$ xianyun up --debug
Over MCP, the same output arrives in the tool result’s stderr.
A cluster won’t start
- Confirm the driver’s program is installed and on your
PATH:k3d versionon Linux,colima versionon macOS.xianyun doctorchecks some of this. - Read the full error. Xianyun names the failing step.
- If the cluster is stuck, recreate it:
$ xianyun destroy
$ xianyun up
Reset in three strengths
Restart. down stops the cluster and up starts it again. Nothing is removed from disk. Use this for an unresponsive cluster or a network problem.
$ xianyun down
$ xianyun up
Recreate. destroy deletes the cluster and its data. up builds a new one. Your config and the files under ~/.local/share/xianyun/, including the dev CA, stay.
$ xianyun destroy
$ xianyun up
Hard reset. Use this when recreating didn’t help. Replace <profile> with xianyun, or with the name k3d cluster list or colima list shows if you use the directory-hash naming strategy:
$ xianyun destroy
$ xianyun update-etc-hosts --remove
$ rm -rf ~/.local/share/xianyun/<profile>
$ xianyun up
$ xianyun ca trust
$ xianyun update-etc-hosts
If destroy fails and leaves a cluster behind, delete it with k3d cluster delete <profile> (Linux) or colima delete <profile> (macOS), then carry on. After a hard reset the CA is regenerated, so restart your browser to load the new one.
Over MCP, destroy, update-etc-hosts, and ca trust are gated, and the rm -rf is yours to run.
More than one cluster
By default there’s one cluster, named xianyun, per machine. To get one per directory, set the naming strategy:
cluster:
name:
strategy: directory-hash
With it, xianyun ca inspect in a temporary directory showed a profile like xianyun-tmp-82hnkhcc8c-260347, so each directory gets its own data under ~/.local/share/xianyun/. Hostname management still covers one cluster at a time unless you give each directory its own hostname.
Switching kubectl context
You don’t need to touch your kubeconfig. xianyun kubectl, xianyun k9s, and xianyun stern use the project’s cluster directly. To merge it into yours:
$ xianyun set-kube-context --use # add it and make it current
$ xianyun set-kube-context --remove # take it out again
If kubectl reports “connection refused” or “host is down”, check that the cluster is running with xianyun status.
Certificates
The CA lives in ~/.local/share/xianyun/<profile>/pki/current/, as ca.crt (safe to share) and ca.key (keep it local). It survives down and up, so you trust it once. To force a new one, delete the pki/ directory and run a command that needs it. See Trusting the dev CA.
Any HTTPS ingress backed by a configuration that declares both builtin:dev-ca and builtin:traefik-tlsstore should then show a valid certificate. That path hasn’t been deployed end to end (Known limitations).
Use cases
Xianyun brings up a production-like local cluster from a shared configuration. Three jobs follow from that.
Validating a change
You or your agent need a real environment to check a feature, reproduce a bug, or demo a workflow, and you want it to be disposable. A shared xianyun.yaml, with optional fragments, lets a less technical contributor turn features on with a one-line change, and Xianyun wraps the technical steps: starting the cluster, trusting a CA, and setting up hostnames.
This is the job Xianyun is built around, and it’s why the MCP server exists: an agent runs validate, bring up, check, reads the results, and decides what to do next.
Developing against production-mode services
Deploying services as Helm charts and Kustomize overlays closes most of the gap between development and production. Edit mode lets a developer run only the code they’re changing against that cluster, so xianyun run swaps the relevant production service for the local process.
The first part works: deployers install into a real cluster. The second doesn’t yet, because the edit-mode supervisor stops after its preflight checks. See Known limitations.
Test environments
The same configuration can stand up an ephemeral environment for automated tests. A suite only needs an endpoint: an API URL, an HTTP address for browser tests, or xianyun kubectl for lower-level checks. The setup in CI has three parts: a container runtime (k3d on a Linux runner), the xianyun binary, and xianyun up followed by your suite and xianyun destroy.
There’s no published release, so a CI job has to build Xianyun or fetch a build you produce. I haven’t run Xianyun in CI as an end-to-end test; the repository’s own end-to-end coverage is the e2e-mock and e2e-k3d NixOS VM tests described in Testing.
Known limitations
Xianyun is a work in progress, and some of what looks supported isn’t. This page lists what doesn’t work yet, so you and your agent don’t spend time on it. Each item has an identifier in the specification, with the evidence.
The built-in overlays haven’t been deployed end to end
helm, kustomize, and namespace deployers work. A NixOS VM test (e2e-deploy) runs xianyun up against a real k3d cluster, installs one of each in dependency order, repeats the deploy, deploys a single deployer with --repo, and checks that a broken chart fails the run and names the deployer. Before 2026-10-14 none of this worked, because no deployer was registered at startup (GAP-001, now closed).
What that test doesn’t cover is path: builtin:dev-ca and path: builtin:traefik-tlsstore. They need cert-manager’s CRDs and a Traefik, which the test VM can’t pull. Treat them as unverified. The workloads in the test are ConfigMaps for the same reason: a deployer that needs a new container image can’t start in that environment, so what’s tested is Xianyun driving Helm, Kustomize, and kubectl, not what the workloads do.
Slow on a cluster that can’t pull images (GAP-009)
Before anything else, up, deploy, and ca inspect try to confirm the cluster’s DNS domain by running a small busybox:1.36 Job and reading its /etc/resolv.conf. If the cluster can’t pull that image (an air-gapped machine, a registry rate limit), each command waits out the probe’s 120-second timeout and carries on with the domain you configured. Measured in the test VMs: up on a running cluster took 122 seconds. Setting cluster.network.cluster_domain doesn’t skip the probe. It only decides what happens when the probe can’t confirm it.
Edit mode is partial (GAP-002, GAP-006)
xianyun run starts reloaders and shuts them down cleanly, but the process supervisor that runs your edit_mode_processes under mirrord stops after its preflight checks. xianyun status returns an error when the cluster is Ready and reloaders are configured. run also doesn’t prepare or clean up mirror deployment copies, or check preconditions.
doctor runs two diagnostics (GAP-003)
The Go tool ran five: cluster domain, orphaned mirror copies, disk space, macOS firewall, and node health. Xianyun runs the first two. A clean doctor result doesn’t rule out the other three.
--no-cache does nothing (GAP-005)
The flag and XIANYUN_NO_CACHE are read and then ignored, because the remote-fragment cache isn’t wired in.
up doesn’t check /etc/hosts (GAP-007)
The Go tool validated the hosts file during up. This one doesn’t.
Names and output (GAP-008)
Many strings still say Caproni, and ca inspect prints raw Rust debug output for its dates.
Over MCP
Gated tools need a client that supports elicitation at protocol 2026-07-28 or newer, and the confirmation can be bypassed by a client that supplies an answer on the first call. run, kubectl, k9s, stern, and tool aren’t exposed. See Confirmation and gated tools.
Open security findings
The threat model lists 35 findings, 12 of them High, and none has been mitigated yet. Security summarises the ones that affect day-to-day use.
Documentation for Caproni
Caproni’s own documentation describes the Go tool and its names, not this one. Where a page you find elsewhere disagrees with this book, this book describes xianyun.
API reference
The Rust API documentation is generated by rustdoc and published with this book. Open it from the links below. If you’re reading the book from a plain mdbook build without the rustdoc step, these links won’t resolve; see Building the documentation.
| Crate | What it holds |
|---|---|
xianyun_core | Configuration loading, merging and validation, variable expansion, naming, ordered maps, topological sort |
xianyun_pki | The dev CA and the trust store integrations |
xianyun_executor | The CommandRunner trait, logging, the log-filter TUI, small file and data-directory helpers |
xianyun_backends | Wrappers for docker, git, helm, /etc/hosts, kubectl, and kustomize |
xianyun_cluster | Cluster drivers, DNS zone discovery, kubeconfig handling |
xianyun_deploy | Deployers, reloaders, the orchestrator, the registry cache, diagnostics |
xianyun_update | Repository updates and lifecycle hooks |
xianyun_infra | Embedded Kubernetes overlays (dev CA, Traefik TLS store) |
xianyun_cli | The xianyun command tree and its wiring |
xianyun_mcp | The MCP server’s allowlist and gating |
xianyun_testutil | The shared mock CommandRunner used by tests |
Many module headers say “Ported from” followed by a Go file name. That points at the original implementation each piece mirrors, which is useful when you’re comparing behaviour. Some headers still say “Not yet implemented” on modules that now are; the source is authoritative.
Project documentation
The rest of this book is for people who build, review, or audit Xianyun. It’s the same material that lives under docs/ in the Go-era layout, reorganised for the Rust workspace.
| Page | What’s in it |
|---|---|
| Vision | Where the project is headed and what “agent-first” means in practice |
| Architecture | The crate layout and the patterns the code follows |
| Testing | The unit suite and the NixOS VM tests |
| macOS | The macOS build host and the macOS-only code |
| Architecture decision records | Why the code is built the way it is |
| Specification | Requirements, with their status and the known gaps |
| User stories | Behaviour, written as testable scenarios |
| Threat model | What can go wrong, scored, with the evidence |
| Documentation review | The state of the documentation, and what’s left |
| Building the documentation | How this book and the API reference are built |
The artifacts cross-reference each other by identifier: ADR-NNNN, FR-, NFR-, IR-, and SR- for requirements, TM-<AREA>-NNN for threats, GAP-NNN for known gaps, and STORY-NNN for stories.
Where Xianyun is headed
Xianyun (閑雲, pinyin Xián Yún — “idle cloud”) started as a Rust rewrite of Caproni, a command-line tool that orchestrates local Kubernetes development environments. The rewrite isn’t just a language swap. It’s aimed at a specific, narrower job than “general dev environment tool”: being a developer-focused validation tool and verification driver, with AI agents as a first-class caller, not an afterthought.
The lineage: Caproni already pointed this way
Caproni’s own documentation already frames its core value this way — see the use-cases page in Caproni’s documentation: spinning up an ephemeral, Helm-deployed instance to validate features, reproduce bugs, or demo workflows, with environment configuration that’s sharable rather than hand-assembled each time.
Xianyun keeps that framing and sharpens it. Where Caproni is a tool a developer runs, Xianyun is a tool built so a verification loop — human or agentic — can run it, read structured output back, and decide what to do next, repeatedly, without a human parsing terminal text in the middle every time.
What “AI agent-first” means concretely
This isn’t a marketing label. It constrains real design decisions:
- Typed, structured results over ad-hoc text. Where Caproni’s Go
code paths some results through free-text log lines, the Rust port
favors
Result<T, E>with specific error variants an agent (or a human-written script) can match on, not a string to grep. - Real concurrency primitives, not best-effort goroutines.
xianyun run’s reloader-supervision engine, for example, usestokio:: task::JoinSetspecifically for its join-before-return guarantee — a verification driver that can’t be sure every supervised process actually finished before reporting success is not trustworthy for an agent to build a decision on. - A real test-first compatibility contract. Every ported behavior has a test proving the same contract the Go original’s test proved, not just “it compiles and seems to work.” An agent-first tool for validation has to itself be validated to that standard, or it’s not fit for the job it claims to do.
- Config and CLI compatibility as a floor, not a ceiling. Existing
caproni.yamlprojects work against Xianyun unmodified (see Caproni compatibility) — migration cost for existing users is zero, so adopting Xianyun for agent-driven workflows doesn’t require re-authoring every project’s config first.
What this doesn’t mean (yet)
This vision statement describes intent, not a finished feature set.
Xianyun now has an MCP server, crates/xianyun-mcp,
that exposes an allowlisted subset of the command tree to agents (see
ADR-0005),
but it doesn’t yet have a structured machine-readable output mode across
every command: only registry status offers -o json. The porting work
tracked in each crate’s PORT_NOTES.md is about reaching behavioral
parity with Caproni first, and parity isn’t complete (the
spec lists the open gaps). Structured
output across the CLI is a deliberate next phase once parity is solid,
not something to half-build alongside it.
See also
crates/README.md— the Rust workspace layout, naming, and per-crate porting status.- macOS notes — the small, explicitly-deferred macOS-only slice of the port.
- Use cases — the validation, development, and test-environment framing this vision extends. It comes from Caproni’s use-case pages, which are in the upstream repository.
Architecture
Xianyun is a Cargo workspace of eleven crates. It’s a deployment orchestrator, not a configuration generator: it starts a cluster, points Helm and Kustomize at what your config lists, and sequences the workflow. Helm is the source of truth for application configuration, and Xianyun knows nothing about any particular application.
The goal is to close the gap between local development and production Kubernetes by composing standard tools (Helm, Kustomize, k3d, Colima, mirrord, Skaffold) rather than inventing abstractions of its own.
A test for every feature: could a team unrelated to the project that wrote it use the feature without changing it? If not, redesign it.
The design came from the Go project this one forked. Its lesson was that tight coupling to one application turns a developer tool into a maintenance burden for every feature team. The Go source isn’t in this repository; see ADR-0017.
Crates
| Crate | Responsibility |
|---|---|
xianyun-core | Configuration loading, merging and validation; variable expansion; naming; ordered maps; topological sort |
xianyun-pki | The dev CA and the trust store integrations |
xianyun-executor | The CommandRunner trait, logging, the log-filter terminal UI, small file and data-directory helpers |
xianyun-backends | Stateless wrappers for docker, git, helm, /etc/hosts, kubectl, and kustomize |
xianyun-cluster | Cluster drivers, DNS zone discovery, kubeconfig handling |
xianyun-deploy | Deployers, reloaders, the orchestrator, the registry cache, diagnostics |
xianyun-update | Repository updates and lifecycle hooks |
xianyun-infra | Embedded Kubernetes overlays (the dev CA and the Traefik TLS store) |
xianyun-cli | The xianyun binary: command tree and wiring |
xianyun-testutil | A shared mock CommandRunner for tests |
xianyun-mcp | The MCP server |
The API reference documents each crate’s public items. Each crate’s PORT_NOTES.md records decisions made while porting it from Go; the source is authoritative where those notes have fallen behind.
What xianyun up does
In the order the code runs them (crates/xianyun-cli/src/commands/up.rs):
- Load the configuration.
- Start the managed registry cache, if one is configured. A failure here is a warning, not an error (ADR-0015).
- Start the cluster through the driver, then fetch its kubeconfig and wait for the API to answer.
- Resolve the cluster’s DNS domain.
- Clone the repositories that edit-mode deployers and reloaders need.
- Prepare the dev CA.
- Run
edit_mode_preparehooks. - Deploy every deployer, in topological order of
needs.
xianyun run then starts the reloaders.
Patterns
Stateless backends, injected runner. Backends are thin wrappers around external tools and hold nothing but an injected Arc<dyn CommandRunner>. Managers orchestrate and hold state, and call tools only through a backend. This is the boundary that makes unit testing possible: inject the shared mock and test a manager’s logic with no helm, kubectl, or docker installed. A manager shouldn’t depend on a concrete backend type, only on its trait. Some paths bypass the runner; ADR-0004 lists them.
One registry shape per domain. Cluster drivers, deployers, and reloaders each have a trait and a global registry of factories (register, new_manager, list_types). Nothing registers itself on startup in Rust, so xianyun_cli::drivers::ensure_registered does it explicitly. It registers the drivers and the three deployers (helm, kustomize, namespace). Registration was missing for the deployers until 2026-10-14, so every configuration with one failed with an unknown-type error (GAP-001, closed); crates/xianyun-cli/tests/deploy_registry.rs now fails if one goes missing.
Conditionals behind the interface. Platform selection belongs inside an implementation, not in callers. driver: auto resolves to k3d or Colima inside driver/auto.rs, and the rest of the code never branches on the operating system. Capability flags on registries are the wrong tool; use configuration (for example cluster.k3d.k3s.disable).
A virtual kubeconfig. Xianyun doesn’t read or write your ~/.kube/config as part of a workflow. Each backend call gets a temporary kubeconfig built from the driver’s output, passed through KUBECONFIG, so Xianyun’s clusters stay isolated from your global config and concurrent operations are safe. The drivers enforce this: Colima starts with --activate=false and k3d with --kubeconfig-update-default=false. The one exception is xianyun set-kube-context, which merges into your kubeconfig because you ran it. The temporary files are created with default permissions today (TM-KUBE-002).
Infrastructure as ordinary deployers. Cert-manager, ingress controllers, and operators are declared by the project as regular deployers. Xianyun contributes none. Two Kustomize overlays are built into the binary, builtin:dev-ca and builtin:traefik-tlsstore, because HTTPS is a prerequisite for production-like local development, and each still needs an explicit deployer to install it. Don’t add a built-in overlay unless it directly enables Xianyun’s own job.
Config as data. Configuration is loaded as a JSON value, merged, expanded, validated against an embedded schema, and only then turned into typed structs (ADR-0009). Application config belongs in the config file, not environment variables, which bypass schema validation. The exception is meta-configuration that decides which file to load.
Concurrency with a join guarantee. xianyun run supervises reloaders with a tokio::task::JoinSet, so every task has finished before success is reported.
Errors as values. Library code returns typed errors. Only the entry point turns them into a process exit code, so no business logic calls std::process::exit.
An allowlisted agent surface. The MCP server exposes a reviewed list and fails the build when a command isn’t classified (ADR-0005).
How a configuration loads
The steps in crates/xianyun-core/src/config/caproni.rs, in order:
- Read the file and parse it into a generic structure.
- Resolve
extends:fragments and merge them under the base. - Merge
xianyun.local.yaml, if present. - Detect the
versionfield, which is required. - Load
xianyun-project.yamlfrom each edit-mode repository directory. - Seed the variable resolver from an unvalidated decode, and resolve the profile name so the predefined variables exist.
- Resolve variable definitions, then expand
${VAR}across the merged tree. - Validate against
schema/config.v2.schema.json. - Decode into typed structs, apply defaults, and upgrade to the latest version.
- Fetch deployer files that came from remote fragments.
- Run semantic validation.
Changing the schema
- Update
schema/config.v2.schema.json. - Update the Rust types in
crates/xianyun-core/src/config/. - Add or update a file in
examples/, and runxianyun config validateagainst it. CI does this for every top-level example. - Update the configuration reference if the change is visible to users.
Mark a field required only if it has no sensible default.
Testing
Managers are unit-tested by injecting xianyun_testutil::MockCommandRunner, which supports first-in-first-out, predicate-matched, and repeating expectations. The NixOS VM tests cover the compiled binary end to end; see Testing. Write the test first, and don’t merge a change that lowers coverage.
Anti-patterns
| Anti-pattern | Why it breaks things |
|---|---|
| Hardcoded resource, namespace, or secret names | Couples the tool to one application |
| Generating manifests or config templates | Xianyun becomes another configuration system next to Helm |
| Business logic in the CLI crate | Untestable without the command-line framework |
| Global mutable state | Hidden dependencies; the registries are the one sanctioned exception, and they’re written once at startup |
| Environment variables for application config | Bypasses validation, discovery, and migration |
| Platform conditionals in front of an interface | They belong inside an implementation |
| Returning raw command output from a backend | Backends return structured data |
| Capability flags on a registry | Use configuration |
#![allow(dead_code)] | Dead code usually means a partial implementation to finish |
Testing
The unit suite
nix develop -c cargo test --workspace runs every crate’s tests. Measured on Linux on 2026-10-14: 1,119 passed, 0 failed, 23 ignored, out of 1,142. Of the ignored tests, most need a live Docker daemon or Kubernetes cluster, a few need root and the real /etc/hosts, and one (compile_schema_lazy_loading in xianyun-core) is an unimplemented placeholder. Dependency scanning is nix develop -c cargo audit; the last run found no vulnerabilities and four warnings, recorded in the threat model.
NixOS VM tests
The unit-test suite mocks the
cluster driver for most of its 1,100+ tests, so it never actually boots
the compiled xianyun binary, parses a real xianyun.yaml from disk, or
exercises the real Cli::parse() -> dispatch() path. This file
documents the NixOS VM tests (pkgs.testers.runNixOSTest) that close
that gap: they build the real binary, boot a throwaway NixOS virtual
machine with QEMU, and drive the binary through its real command-line
interface inside it.
They’re registered as checks.<system> flake outputs, on
x86_64-linux and aarch64-linux only. NixOS VM tests require Linux
(they need the Linux KVM/QEMU path); there’s no equivalent for
aarch64-darwin, so that system has no checks entry at all.
Running them
$ nix build .#checks.x86_64-linux.e2e-mock -L
-L streams the full VM boot log and test-script output, which is worth
having the first time something fails. Swap in
.#checks.aarch64-linux.e2e-mock on an aarch64 Linux host; this repo’s
own development machine is x86_64-linux, so that output is evaluated and
built on CI/aarch64 hosts, not exercised here (see “What’s verified vs.
evaluated” below).
nix flake check runs every checks.<system>.* output for every system
in flake.nix’s systems list, which includes aarch64-linux – on an
x86_64-linux machine that means either a slow QEMU-emulated cross-build
or skipping it. Prefer building the specific checks.<system>.<name>
output for the system you’re actually on.
Tier 1: e2e-mock (nix/tests/e2e-mock.nix)
A NixOS VM containing only the compiled xianyun binary – no Docker,
no k3d, no kubectl, nothing beyond what a minimal NixOS closure
already has. The test script:
- Writes a real
xianyun.yamlto disk withcluster: { driver: mock }. - Runs
xianyun versionandxianyun config validate/xianyun config printagainst that file, asserting each exits0and thatconfig printactually echoes the configuredmockdriver back – this exercises the real (non-mocked) YAML parser and config-file-discovery walk, not a hand-builtContextthe way the unit tests do. - Runs
xianyun doctor, asserting only that it doesn’t panic (this VM deliberately has none of helm/kustomize/git/k3d/docker installed, so a non-zero exit reporting missing tools is expected and fine). - Runs
xianyun status,xianyun down, andxianyun destroyagainst a cluster that was never started, asserting each exits0– confirming the mock driver’s idempotent lifecycle calls work when invoked through the real binary’s dispatch path, not just through arun_status/run_stop/run_destroycall built in a Rust test. - Greps every command’s combined output for
panicked at, failing the test if any command panicked regardless of its exit code.
Every assertion in this test runs in well under a minute once the VM
boots (VM boot + test script: under 30 seconds on this machine, plus
whatever time nix build spends compiling the binary the first time;
rebuilds with a warm cargo/nixpkgs cache are much faster).
Why xianyun up isn’t in Tier 1
cluster.driver: mock only fakes the cluster lifecycle calls
(start/stop/destroy/status) – up::run_start (the real up
handler) still builds a genuine kubectl client pointed at whatever
kubeconfig the driver reports, in order to wait for the cluster API to
answer, regardless of which driver is configured. The mock driver’s
get_kubeconfig() returns a fake value with no API server behind it, so
a real xianyun up invocation needs either a live Kubernetes API (Tier
2) or a kubectl binary that will reliably fail fast rather than
hang the test retrying for up’s 180-second cluster-ready timeout. This
was confirmed by running the test against the real binary rather than
assumed – see “A regression this test already caught” below.
A regression this test already caught
Writing this test immediately surfaced a real bug: commands::dispatch
resolved the config file path via hooks::before_hook but then
discarded the result, and every subcommand’s public run() wrapper
built a fresh, empty Context::default() instead of using it. Every
unit test for up/down/destroy/status/ca/config/etc. calls
the inner run_start/run_stop/run_destroy/… function directly
with a manually constructed Context, bypassing run() entirely, so
the whole suite stayed green while the real binary failed every
config-reading subcommand with configuration file not found: no global config in context. This is exactly the class of regression a real,
disk-and-binary VM test catches and a mocked-driver unit test cannot.
Fixed in the same change that added this test: dispatch() now builds
the Context from before_hook’s resolved GlobalConfig once and
threads it into every subcommand handler that needs it.
Tier 2: e2e-k3d (nix/tests/e2e-k3d.nix) – real k3d-backed lifecycle
A NixOS VM with virtualisation.docker.enable = true plus
k3d/kubectl/docker-client installed, running xianyun up against
cluster.driver: k3d to create a genuine k3d/k3s cluster inside the
VM’s Docker-in-VM, verifying it’s actually ready with a real kubectl get nodes (not through xianyun’s own reporting), then xianyun down
/ xianyun destroy to tear it down and confirming no k3d- containers
or clusters are left behind.
A previous session investigated this and deliberately didn’t ship it (see below for why that call made sense at the time); this session closed the gap it identified.
The sandbox problem, and the fix
k3d cluster create needs container images that aren’t part of the
base NixOS closure, and Nix’s build sandbox has no network access by
design – a nixosTest VM’s build still runs inside that sandbox, so
pulling any of them over the network isn’t possible. Four images turned
out to be needed, found empirically (not guessed from memory) by
running this test and reading exactly which pull failed next each time:
| Image | Tag | amd64 manifest digest | Why it’s needed |
|---|---|---|---|
rancher/k3s | v1.32.5-k3s1 | sha256:92fc5b4a72ec357c4e0f0a5171f3ea38e3ff569d0cf9735a62151c2ede3c4266 | The k3s server/agent image itself; this is k3d v5.9.0’s own default k3s version on this flake’s pinned nixpkgs revision. |
ghcr.io/k3d-io/k3d-proxy | 5.9.0 | sha256:9181bb55f3c04c86510af3526dda86baa9abc389ef988a3c4bb4879b32d8c21c | The “serverlb” load-balancer container k3d creates by default in front of the cluster’s exposed ports. xianyun-cluster’s k3d driver has no --no-lb knob to skip it (confirmed by reading build_cluster_create_args in crates/xianyun-cluster/src/driver/k3d.rs), so it’s unavoidable. |
ghcr.io/k3d-io/k3d-tools | 5.9.0 | sha256:f18c1e21aba123fe6ff315c514a0feffc005f587aa3db773e89489c2118842dd | A short-lived helper container k3d starts for every cluster create, independent of the other two – its absence was the first failure this test hit (“docker failed to pull image ‘ghcr.io/k3d-io/k3d-tools:5.9.0’ … lookup ghcr.io: no such host”). |
rancher/mirrored-pause | 3.6 | sha256:c2280d2f5f56cf9c9a01bb64b2db4651e35efd6d62a54dcfc12049fe6449c5e4 | The sandbox image containerd needs inside the k3s node to schedule any pod at all (coredns, traefik, local-path-provisioner, metrics-server). This one is pulled by a different image store than the other three – see below. |
Each is pre-fetched as its own pkgs.dockerTools.pullImage fixed-output
derivation (nix/images/k3s.nix, nix/images/k3d-proxy.nix,
nix/images/k3d-tools.nix, nix/images/pause.nix). FODs are allowed
network access during their own build because their output is verified
against a pinned content hash afterwards – this is the standard,
sandbox-compatible way nixpkgs gets any network-sourced content (a
tarball, a container image, a source archive with no reproducible
build step of its own) into a build without breaking reproducibility.
Each derivation’s imageDigest is the image’s manifest digest (from
skopeo inspect --raw docker://<image>:<tag>, picking the amd64/
linux platform entry from the manifest list/index); its hash is the
hash of the pulled tarball itself, found by building the derivation
once with a wrong hash and reading the mismatch Nix reports.
The first three images back containers the VM’s outer Docker
daemon creates directly (the k3s node, the serverlb, the tools
container), so this test’s build loads them with docker load before
xianyun up ever runs k3d cluster create, and pins
cluster.k3d.image in the generated xianyun.yaml to the exact k3s
tag that was pre-loaded – k3d never has a reason to reach a registry.
The pause image is different: it’s pulled by the k3s node’s inner
embedded containerd, a separate image store docker load on the outer
VM can’t reach. The test script instead runs k3d image import <pause-tarball> -c xianyun once the cluster exists (right after
xianyun up returns), which pushes the tarball through the cluster’s
shared image volume into the node’s containerd; kubelet’s own retry
backoff picks it up and system pods recover shortly after. Nix shares
its host store read-only into the VM, so the test script references
the FOD store paths directly – the same idiom nixpkgs’ own
nixos/tests/docker-tools.nix uses for pullImage examples; no
copying needed.
A second bug this test caught: kubectl::Client ignored its own kubeconfig
Pinning every image got k3d cluster create to succeed, but xianyun up then failed with “the connection to the server localhost:8080 was
refused” – kubectl’s hardcoded fallback when it can’t find a
kubeconfig anywhere. xianyun-cluster’s k3d driver creates clusters
with --kubeconfig-update-default=false (so it never writes to
~/.kube/config), and kubectl::Client (crates/xianyun-backends/src/ kubectl/mod.rs) carried a kubeconfig field from with_config but,
per its own doc comment, never acted on it – a known, documented gap
(see crates/xianyun-backends/PORT_NOTES.md), but one that made up
against any real driver (k3d, colima) non-functional whenever the
cluster’s kubeconfig differs from the ambient default, which is the
common case. Fixed in the same change that shipped this test:
kubectl::Client now materializes its kubeconfig into a temp file and
points KUBECONFIG at it (mirroring k3d::Driver’s
disposable_kubeconfig_opts) on every command it runs – read_stdout,
run_manifest_command, and get_version’s retry loop. New unit tests
(*_sets_kubeconfig_env_when_with_config /
*_sets_no_env_override_when_plain_new in kubectl::mod::tests) cover
the behavior with a dedicated EnvCapturingRunner, since
xianyun_testutil::MockCommandRunner doesn’t inspect Options::env.
This is exactly the class of regression e2e-mock’s own doc comment
already called out: a bug only a real binary, driving a real cluster
through its real CLI, can catch.
Running it, resource requirements, and timing
$ nix build .#checks.x86_64-linux.e2e-k3d -L
Observed end to end on this machine (default Nix sandbox, no
--option sandbox relaxed, no sandbox = false anywhere): VM boot,
docker load of the three outer images, xianyun up (k3d cluster
create plus the cluster-API-ready wait), k3d image import of the
pause image, xianyun status, a real kubectl get nodes showing the
node Ready, xianyun down, xianyun destroy, and a final check that
no k3d- containers or clusters remain – test script: ~220
seconds (plus whatever nix build spends compiling the binary and
building the VM closure the first time; rebuilds with a warm
cargo/nixpkgs cache are faster). virtualisation.memorySize = 4096; virtualisation.cores = 4; was sufficient for k3s + containerd plus the
four preloaded images on this machine’s hardware; virtualisation. diskSize = 8192 (MiB) gives the preloaded images and k3s’s own
container layers headroom. If a future k3d/k3s version bump needs
more, bump these first before suspecting anything else.
Updating the pin
When bumping this flake’s nixpkgs (which can move k3d’s version),
re-run nix develop -c k3d --version to get the new default k3s tag,
then re-derive each image’s digest with skopeo inspect --raw docker://<image>:<tag> (picking the amd64/linux manifest from the
list/index) and rebuild each nix/images/*.nix derivation once with a
deliberately wrong hash to read the correct one from the mismatch Nix
reports – the same process used to pin them the first time, documented
in each file’s own doc comment.
Scenario tests
Five more NixOS VM tests run the compiled binary against a real k3d cluster for one behaviour each. They share nix/tests/lib.nix (the VM definition and the Python helpers) and are described in ADR-0019. They replace the Go project’s integration suites; the specification lists which suite each one succeeds and how it differs.
| Check | Scenario | Measured run time |
|---|---|---|
e2e-deploy | Namespace, Kustomize, and Helm deployers; needs ordering; repeat deploy; --repo; a failing deployer | about 600 seconds of test script; up 162 s, each deploy about 122 s |
e2e-no-ingress | k3s without Traefik; the project’s own deployer instead | up about 155 s, twice (a control and the scenario) |
e2e-ca-drift | Re-signing the dev CA with the same key when names change | up 151 s, then 122 s; checked with openssl |
e2e-cells | Two environments from sibling directories | 490 s of test script, in an 8 GiB, 8-core VM |
e2e-registry-mirror | A cluster pulling through a bring-your-own zot | one cluster |
$ nix build .#checks.x86_64-linux.e2e-deploy -L
These need /dev/kvm, a few GiB of RAM each, and several minutes. Run them one at a time. The times above are from one run on a 16-core, 62 GiB Linux host, not benchmarks.
Every image a scenario needs is a fixed-output derivation under nix/images/, because the Nix sandbox can’t pull. That is why the workloads are ConfigMaps: a deployment that needs a new image can’t start. It also means these tests can’t cover the builtin:dev-ca and builtin:traefik-tlsstore overlays, which need cert-manager’s CRDs and a Traefik.
What writing them found
- Default logging was broken. A command run with no logging flags logged nothing, because
LoggingArgs::default()held empty strings and the resulting configuration failed to parse, with the error discarded. Thee2e-deployordering check couldn’t see any log line, which is how it surfaced. Fixed, with unit tests, and an invalid logging flag is now an error (GAP-010). - Domain discovery waits two minutes on a cluster that can’t pull.
up,deploy, andca inspecteach run abusybox:1.36probe first and wait out its 120-second timeout when it can’t start (GAP-009). Not fixed: the order was ported from the Go tool. - No deployer was registered in production (GAP-001), which is the reason the deploy scenario exists.
Two mistakes in the tests themselves are worth knowing about. The NixOS test driver lints and type-checks the Python script, so an unused import or a variable named log fails the build before the VM boots. And a control that “fails” proves nothing unless you check why: the first registry-mirror control failed because k3s crictl doesn’t exist, not because the pull failed.
What’s verified vs. evaluated
checks.x86_64-linux.e2e-mock: built and run on this machine (the development host isx86_64-linux); passes.checks.x86_64-linux.e2e-k3d: built and run on this machine; passes under the default Nix sandbox. See the Tier 2 section above for the full run.checks.x86_64-linux.e2e-deploy,e2e-no-ingress,e2e-ca-drift,e2e-cells,e2e-registry-mirror: each built and run to a pass on this machine on 2026-10-14 against the final code.checks.aarch64-linux.*: the same derivations, parameterized bypkgs.testers.runNixOSTest’s target system.nix flake showandnix evalconfirm they evaluate to valid derivations; neither has been built or run here, since that would require emulated cross-compilation of the entire Rust workspace plus an emulated QEMU-in-QEMU VM boot, which is impractically slow on this machine. Build them on a native aarch64-linux host (or a CI runner with one) to actually execute them –e2e-k3d’s four pinned images areamd64-only manifest digests (see the table above), so that host will also needarm64digests re-derived the same way before itse2e-k3dcan pass;e2e-mockhas no such dependency.
macOS work for the Xianyun (閑雲) rewrite
This rewrite was developed on Linux. Most of it is platform-independent and verified there. A small, well-scoped slice is macOS-only. This document lists exactly what that slice is and how the macOS build/test environment is set up.
Status: a macOS build host is now available
An AWS EC2 mac2 (Apple silicon, aarch64-darwin, macOS 27) instance is
set up as a build/test host. scripts/bootstrap-nix-macos.sh installs
Nix on it and configures the environment; nix develop -c cargo test --workspace runs the full suite there.
Verified on macOS as of this writing: the entire workspace builds on
aarch64-darwin — including truststore::system_darwin, which is
#[cfg(target_os = "macos")]-gated and never compiled on Linux — and the
full suite was green with 0 failed and 23 ignored (the remaining ignores
are live docker/k8s integration tests, not platform gaps). The passing
total was reported as 1,076 here and 1,090 in crates/README.md; neither
was re-measured, so see the README for the current figures. The 10
macOS Keychain tests are implemented and passing; see below.
Headless-macOS Nix gotcha (captured in the bootstrap script)
On this macOS 27 host the stock multi-user org.nixos.nix-daemon
LaunchDaemon can’t start: launchd sandboxes it so it can’t read the
synthetic /nix firmlink volume (dyld: ... file system sandbox blocked open() → big-lock: Permission denied). There’s no GUI to grant Full
Disk Access and SIP is on, so the sandbox/TCC can’t be edited. Direct
(daemonless) builds work fine, so scripts/bootstrap-nix-macos.sh
switches the box to daemonless single-user operation (store owned by
ec2-user, NIX_REMOTE= exported in the login profile, plus a small
RunAtLoad LaunchDaemon that mounts /nix at boot). This is only safe
because the instance is single-user; do not do it on a shared Mac.
What was deferred
This rewrite was developed primarily on Linux. The macOS-only slice below was read against its Go original first and implemented once the macOS host above became available.
xianyun-pki’s macOS Keychain integration (10 tests) — DONE
crates/xianyun-pki/src/truststore/system_darwin.rs ports Go’s
internal/truststore/system_darwin.go — installing/removing the dev CA
from the macOS system Keychain via security add-trusted-cert /
remove-trusted-cert / find-certificate / delete-certificate. The 10
tests are gated #[cfg(target_os = "macos")] (matching the Go original’s
//go:build darwin tag) and don’t compile on Linux.
These are now implemented and verified on the macOS host described above.
cargo test -p xianyun-pki on macOS runs 43 tests (the 33 shared plus
these 10), all passing. The implementation drives the mocked
CommandRunner, so it does not touch the real Keychain or prompt during
the test run. removeSupersededSystem and sameKeyCert — the two pieces
left stubbed in Phase 3 — are ported (supersede.rs, system_darwin.rs,
ca::reissue_same_key); see crates/xianyun-pki/PORT_NOTES.md’s “macOS
tests” section for the detail and the one deterministic-temp-path
adaptation.
Live-cluster integration tests that assume a Darwin-flavored colima (subset)
A handful of #[ignore = "integration: ..."] tests across
crates/xianyun-cluster/ and crates/xianyun-cli/ exercise the colima
driver against a real colima binary and a real Docker/Lima VM. These
aren’t macOS-exclusive in principle — colima also runs on Linux via
QEMU — but Caproni’s own Go test suite and this port both treat colima as
the macOS development path, with k3d as the Linux-native equivalent.
The driver logic itself is already fully ported and unit-tested against
mocks (crates/xianyun-cluster’s colima module: 32/32 passing on
Linux), so this is a live-integration verification gap, not an
implementation gap.
On the macOS host above, run the full suite with --include-ignored
and colima/docker actually working, and spot-check that the
colima-specific integration tests pass against a real VM. The repository has
no record of that run.
What’s NOT deferred (already fully covered on Linux)
Everything else in the workspace — including the colima driver
implementation (as opposed to a live colima integration run), k3d,
vcluster, external, and mock drivers, every backend, every deployer,
the executor/async layer, and the CLI wiring — is implemented and tested
on Linux already. macOS access unblocks exactly the two items above, not
a general “finish the rewrite on macOS” task.
flake.nix readiness
flake.nix’s devShells.default already adds colima conditionally via
lib.optionals stdenv.hostPlatform.isDarwin, so nix develop on macOS
picks it up automatically with no changes needed — the Linux shell simply
omits it since it isn’t meaningfully usable there in this project’s
context (Caproni expects it on Darwin, QEMU-backed Linux colima is a
different support story the Go original doesn’t document either).
Architecture decision records
An ADR records one decision: the situation, the options, the choice, and what it cost. Accepted ADRs aren’t edited. A changed decision gets a new ADR that supersedes the old one.
| ADR | Decision |
|---|---|
| 0001 | Record architecture decisions as ADRs |
| 0002 | Rewrite Caproni in Rust as Xianyun |
| 0003 | Compatibility aliases for config, environment, and data paths |
| 0004 | Synchronous CommandRunner as the only process seam |
| 0005 | Allowlist-only MCP server with elicitation gating |
| 0006 | Name-constrained dev CA stored per profile |
| 0007 | Pull-through registry cache, bring-your-own by default with an optional managed zot |
| 0008 | Tiered NixOS VM tests with pinned image digests |
| 0009 | Version 2 configuration only, held as a JSON value and validated against an embedded schema |
| 0010 | mdBook and rustdoc for the Xianyun documentation |
| 0011 | Colima on macOS and k3d on Linux as the local cluster drivers |
| 0012 | mirrord for edit mode, with Skaffold as the fallback |
| 0013 | zot as the managed registry cache implementation |
| 0014 | Registry cache lifecycle: auto-start, never auto-stop, one shared instance |
| 0015 | The registry cache is opt-in and fails soft |
| 0016 | Containerd mirror wiring for the registry cache, and Colima on containerd |
| 0017 | Remove the Go implementation, and publish the documentation through GitLab Pages |
| 0018 | Xianyun collects no telemetry |
| 0019 | NixOS VM scenario tests for the end-to-end behaviour |
Still to migrate
One decision from the Go project’s log isn’t an ADR: using locally rendered templates for edit mode, and deferring Cloud Native GitLab images as the single source of truth. It concerns GitLab’s container images and isn’t yet reflected in Xianyun’s code. The GitLab-specific decision to share one Postgres pod across databases is also not carried over.
ADR-0001: Record architecture decisions
Status: accepted · Date: 2026-10-13 · Decision makers: James Hebden
Context and Problem Statement
Decisions for this project live in three places that don’t agree. The Go project’s docs/contributing/decisions.md was a table of nine alpha-era decisions, two of which were still marked “Proposed” although the behaviour they described had shipped. Its docs/design/registry-cache.md embedded twelve numbered “ADR” sections inside a design document. The Rust rewrite added many more choices that were only written down as porting notes in crates/*/PORT_NOTES.md. A reader can’t tell which record is current.
Decision Drivers
- Decisions should sit next to the code and be reviewed in the same merge requests
- Reversals need a visible trail, so a decision that changes should say what it replaces
- The threat model and the spec need stable identifiers to link to
- The existing decision log is a deliberate stopgap (“an intermediate approach to ADRs”), and its own text says to convert it
Considered Options
- Markdown ADRs in
book/src/project/adr/, MADR 4.0.0 format, numbered sequentially - Keep extending the decision log table
- Track decisions in an external wiki or issue tracker
Decision Outcome
Chosen option: “Markdown ADRs in book/src/project/adr/”, because they get reviewed with the change, carry full git history, and give the threat model and spec something stable to link to. Option 2 stays readable but has no per-decision rationale or alternatives, and the table is already out of date. Option 3 was rejected because the decision log’s own header notes that Slack threads and similar sources expire.
Consequences
Good:
- Each decision records its alternatives and trade-offs
- Cross-references from threats and requirements have a target that won’t be renumbered
Bad:
- Existing decisions had to be migrated. ADR-0011 through ADR-0016 carry over the alpha decision log and the registry-cache design; the one row not carried over is the GitLab-specific Postgres decision, which isn’t about Xianyun.
- Writing ADRs is a recurring cost on every significant change
Neutral:
- The old decision log is gone with the Go documentation (see ADR-0017); its upstream copy is at the pinned commit
More Information
- Retroactively documented on 2026-10-13.
- Still unmigrated: the decision to use locally rendered templates for edit mode (deferring CNG-as-single-source-of-truth), which is about GitLab’s container images and not yet reflected in Xianyun’s code.
- Related: threat model, spec.
ADR-0002: Rewrite Caproni in Rust as Xianyun
Status: accepted · Date: 2026-09-28 · Decision makers: James Hebden
Context and Problem Statement
Caproni is a Go CLI that orchestrates local Kubernetes development environments. The fork’s goal is narrower than the original: a developer-focused validation tool and verification driver that an AI agent can run in a loop, read structured results from, and act on without a person parsing terminal text in between. Go’s error strings and best-effort goroutines make that contract harder to guarantee than Rust’s typed errors and join-before-return task sets.
Behaviour that existing users depend on (config files, CLI surface, cluster profile names) has to survive the change.
Decision Drivers
- Typed results and specific error variants that callers can match on
- Concurrency with a guarantee that every supervised task finished before success is reported
- A test-first compatibility contract: each ported behaviour has a test proving the same thing the Go test proved
- Zero migration cost for existing
caproni.yamlprojects - Ability to build and test hermetically with Nix
Considered Options
- Rewrite in Rust as a new workspace beside the Go code, keeping the Go tree as the reference
- Keep Go and add a structured-output mode and an MCP server to the existing code
- Wrap the Go binary from a thin Rust or Python agent shim
Decision Outcome
Chosen option: “Rewrite in Rust beside the Go tree”, because the typed-error and concurrency properties are the point of the fork, and a wrapper (option 3) would inherit Go’s untyped failures. Option 2 is the cheapest, and its main weakness is that join-before-return and exhaustive error matching are conventions in Go rather than properties of the language.
The rewrite proceeds in phases: port the Go test suite as ignored tests (red), implement crate by crate until they pass (green), then add agent-first interface work (structured output, tool surface) once behavioural parity is solid.
Consequences
Good:
- On Linux, 1,119 tests pass, 0 fail, and 23 are ignored (measured 2026-10-13 with
cargo test --workspace); 22 of the ignored have a documented reason and one,compile_schema_lazy_loading, is anunimplemented!stub - The MCP server, typed errors, and
JoinSetsupervision inxianyun runwere possible because of the rewrite
Bad:
- Two implementations live in one repository. Documentation still describes the Go binary almost everywhere (see the all-aboard report for the list).
- Parity isn’t complete:
edit-mode-process-supervisoris a stub;doctorhas 2 of the Go tool’s 5 diagnostics. - The
serde_yamlcrate used for parsing is deprecated
Neutral:
- The binary was renamed from
capronitoxianyun; see ADR-0003
Implementation Location
crates/xianyun-core/src/lib.rscrates/xianyun-cli/src/main.rscrates/README.mdbook/src/project/vision.md
More Information
- Retroactively documented on 2026-10-13. The date is that of the first workspace commit,
feat(rust-rewrite): scaffold Xianyun Rust workspace. - Related: FR-001, NFR-001, ADR-0018 (the Go tool’s telemetry wasn’t carried over).
ADR-0003: Compatibility aliases for config, environment, and data paths
Status: accepted · Date: 2026-10-13 · Decision makers: James Hebden
Context and Problem Statement
Renaming caproni to xianyun touches things users have on disk and in their shells: config filenames, environment variables, the data directory that holds the dev CA and the registry cache, the trusted certificate’s name, and the names of Kubernetes objects. Some of those are cheap to alias and some aren’t. The project had to decide which carry forward and which change cleanly.
Decision Drivers
- An existing Caproni project must work unmodified
- State on disk (the trusted CA, the registry cache) must not be orphaned or double-created
- Names visible inside clusters are referenced by user manifests and certificates
- Each alias is permanent maintenance
Considered Options
- Alias config, environment, and data-path names; rename in-cluster objects with no alias
- Alias everything, including in-cluster object names
- Rename everything with a one-time migration command
Decision Outcome
Chosen option: “Alias config, environment, and data-path names; rename in-cluster objects”, because those first three are read by Xianyun itself and can be handled in one place, while an in-cluster alias would mean creating duplicate objects (two issuers, two Secrets) that drift.
Concretely, the primary names are xianyun.yaml, xianyun.local.yaml, xianyun-project.yaml, XIANYUN_* variables, and ~/.local/share/xianyun. The caproni.* equivalents are read when the primary doesn’t exist; a xianyun.* file wins if both exist. extends: fragments may be named xianyun*.yaml or caproni*.yaml. The CAPRONI_* environment aliases remain for NO_CACHE and the three predefined cluster variables. The ClusterIssuer and Secret are named xianyun-ca.
Consequences
Good:
- Existing projects load without edits
- Legacy trust-store entries are found and cleaned up on uninstall
Bad:
- Behaviour is split across two names for the lifetime of the aliases, and some places still use the old one: the CA’s common name is still “Caproni Dev CA”, the kubeconfig backup suffix is
.caproni.bak, andk3d.rsandcolima.rshard-code.local/share/caproniwhiledatadirresolves toxianyunwith a fallback. State can end up split across two directories. - The user documentation still says
caproni-cain places, so following it against the Rust binary would reference an issuer that doesn’t exist.examples/own-infrastructure.yamlpasses--default-issuer-name=caproni-ca. schema/config.v2.schema.jsonstill limitsextendsentries tocaproni.*.yaml; the unit test passes, so the pattern is probably applied before schema validation, which hasn’t been confirmed end to end.
Neutral:
- Removing an alias later needs a deprecation window and a new ADR
Implementation Location
crates/xianyun-core/src/config/global.rscrates/xianyun-core/src/config/merge.rscrates/xianyun-core/src/naming.rscrates/xianyun-executor/src/datadir.rscrates/xianyun-pki/src/truststore/system_linux.rs
More Information
- Retroactively documented on 2026-10-13 from
README.md(“Configuration compatibility”) andcrates/xianyun-deploy/PORT_NOTES.md. - Related: FR-001, TM-KUBE-001.
ADR-0004: Synchronous CommandRunner as the only process seam
Status: accepted · Date: 2026-09-29 · Decision makers: James Hebden
Context and Problem Statement
Xianyun drives other programs: helm, kubectl, kustomize, git, k3d, colima, docker, mirrord, skaffold, and sudo. Every one of those calls is a place where behaviour depends on the host, where tests need to avoid the real tool, and where a careless argument can change what runs. The Go original handled this with an interface plus mockery-generated mocks per backend. The Rust port needed a single, uniform approach.
Decision Drivers
- Unit tests must run with no helm, kubectl, or docker installed
- Argument construction is the main injection surface, so it should be reviewable in one place
- No shell: values reach argv as separate elements
- Porting the Go test suite one-for-one is easier if signatures don’t change
- Backends stay stateless; dependencies arrive through constructors (project rule in
AGENTS.md)
Considered Options
- A synchronous
CommandRunnertrait, bridged totokio::processthrough one process-wide runtime, injected asArc<dyn CommandRunner> - An
async fntrait withtokio::processthroughout - Call
std::process::Commanddirectly in each backend and test with fake binaries onPATH - Per-backend mock traits generated with
mockall
Decision Outcome
Chosen option: “Synchronous CommandRunner trait”, because it keeps every ported call site and test signature identical to Go’s and gives one mock (xianyun-testutil::MockCommandRunner, with FIFO, predicate, and repeating expectations) for the whole workspace. Option 2 is cleaner in the long run and was explicitly deferred: it would be a breaking change across every crate during the parity phase.
The trait is executor::exec::CommandRunner. Backends in xianyun-backends hold only an injected runner. TokioCommand::new(name).args(args) is the primary spawn path; six direct std::process::Command call sites bypass it (see Consequences). sh -c appears only in tests.
Consequences
Good:
- Hermetic unit tests across all backends and cluster drivers
- No shell anywhere, so metacharacters in config values don’t expand
Bad:
- Calling any runner method from inside a Tokio runtime panics (“Cannot start a runtime from within a runtime”). Nothing does today;
spawn_blockingis the workaround. - The seam is leaky. Six raw
std::process::Commandcall sites in five files bypass it:pki/truststore/nss.rs:36,68(certutil),cluster/clustertool.rs:215(k9s, kubectl, stern, tool),deploy/registrycache/process.rs:77(zot),cli/commands/editmodeprocesssupervisor.rs:221, andexec/mod.rs:509(run_sudo). Those paths can’t be mocked at this boundary and are the main source of the roughly 10 percent of lines without coverage. - Temporary kubeconfigs for helm and kubectl are written with
std::fs::writeand a predictable name (TM-KUBE-002) sudoandzotare resolved throughPATH(TM-EXEC-005)- Positional arguments are passed as given.
git clone <url>,helm pull <ref>, andkustomize build <target>have no--separator (TM-CFG-005). - If
sudoisn’t found,apply_sudologs a warning and runs the command unprivileged rather than failing - Two different shell-word splitters exist (TM-EXEC-002)
Neutral:
run_in_repositorywraps commands inmise exec --whenmiseis onPATH
Implementation Location
crates/xianyun-executor/src/exec/mod.rscrates/xianyun-testutil/src/lib.rscrates/xianyun-backends/src/lib.rs
More Information
- Retroactively documented on 2026-10-13. The date is that of the shared mock crate,
xianyun-testutil. The trait itself is a port of Go’sCommandRunner(introduced 2026-02-25); seecrates/xianyun-executor/PORT_NOTES.mdfor the architecture note. - The architecture page and
AGENTS.mddescribed the Go mockery approach until they were rewritten for Rust on 2026-10-13. - Related: NFR-002, SR-009, TM-HOSTS-001.
ADR-0005: Allowlist-only MCP server with elicitation gating
Status: accepted · Date: 2026-10-02 · Decision makers: James Hebden
Context and Problem Statement
The fork’s stated goal is that an AI agent can drive validation loops. The Model Context Protocol is the common way to hand an agent a tool surface. brontes can walk a clap command tree and expose every reachable command as a tool, which is convenient and dangerous: xianyun tool runs any executable, and kubectl forwards arbitrary arguments. An earlier incident in another CLI (glab #8153) turned an unreviewed editor-launching command into an arbitrary-execution primitive.
Decision Drivers
- Nothing should become agent-callable without someone naming it
- Destructive operations need a human in the loop, not just a hint
- A new CLI subcommand shouldn’t silently widen the tool surface
- Reuse the CLI’s own code paths rather than a second implementation of each command
Considered Options
- Allowlist exact commands, classify them into tiers, gate the destructive tier with MRTR elicitation, and fail the build if a command is unclassified
- Expose the whole command tree with annotations only
- Hand-write a separate set of MCP tools that call library functions directly
- Don’t ship an MCP server
Decision Outcome
Chosen option: “Allowlist with tiers and gating”, because it makes the tool surface a reviewed list and turns drift into a failing test (tests/guardrail.rs). Option 2 was rejected for the tool and kubectl problem. Option 3 would duplicate command logic and diverge from the CLI.
There are 20 tools in three tiers: 7 read-only, 8 state-changing and treated as reversible, and 5 destructive and confirmation-gated (down, destroy, update-etc-hosts, ca trust, ca untrust). Six commands are excluded outright: run, tool, kubectl, k9s, stern, and edit-mode-process-supervisor. Each tool call re-executes xianyun with the matched subcommand’s argv.
Consequences
Good:
- The exposed surface is one file (
crates/xianyun-mcp/src/config.rs) with a golden snapshot (crates/xianyun-mcp/tests/snapshots/tools.json) - Clients without elicitation support get a tool error and the destructive command doesn’t run (asserted in
crates/xianyun-mcp/tests/protocol.rs)
Bad:
mcp streambinds0.0.0.0when--hostis empty (the default comes frombrontes) and has no authentication (measured 2026-10-13) (TM-MCP-001)- The “reversible” tier includes
up,deploy, andupdate, which can run hook commands from config (TM-MCP-002) - The gate is stateless and trusts a client-supplied answer: measured 2026-10-13, a first call carrying
{"confirm": {"action": "accept"}}ranxianyun down(TM-MCP-003) - Every tool takes free-form
argsandflags, so a gated command can still receive a harmful flag value (TM-MCP-004) - All tools report
openWorldHint: false, including those that fetch from the network (TM-MCP-005) - No audit record of calls or confirmations (TM-AUD-001)
Neutral:
- The vision page said there was no MCP-style tool interface until it was corrected on 2026-10-13
Implementation Location
crates/xianyun-mcp/src/config.rscrates/xianyun-mcp/src/main.rscrates/xianyun-mcp/tests/guardrail.rscrates/xianyun-mcp/tests/protocol.rs
More Information
- Retroactively documented on 2026-10-13. The date is that of
feat(mcp): add xianyun-mcp. - Related: FR-014, SR-001 through SR-005, STORY-002.
- Follow-up: a superseding ADR should record the decision on binding, authentication, and where
up,deploy, andupdatesit once the threat model items are resolved.
ADR-0006: Name-constrained dev CA stored per profile
Status: accepted · Date: 2026-09-29 · Decision makers: James Hebden
Context and Problem Statement
Local clusters terminate TLS for hostnames like gitlab.test. Browsers and tools should trust those certificates without click-through warnings. That means a CA the developer trusts system-wide, and a way for the cluster’s cert-manager to issue certificates from it. A CA that’s trusted everywhere and has its key sitting on disk and in a cluster is a sharp tool, so the design needed to limit what it can sign.
Decision Drivers
- Trust only what the developer’s own environment needs
- No manual certificate steps for the common case
- The cluster has to hold signing capability for cert-manager to issue leaf certificates
- Regeneration and rotation shouldn’t leave a half-written CA visible
- Existing trusted CAs from Caproni shouldn’t be orphaned
Considered Options
- Generate an ECDSA P-256 CA per profile with Name Constraints (the default subtrees
.local,.test, and.svc, plus the configured hostname and cluster domain when they fall outside those); store it under the data directory; deploy it to the cluster as a Secret for cert-manager - Shell out to
mkcert - Use an unconstrained CA
- Use a CA generated inside the cluster only, and export its certificate for trust
Decision Outcome
Chosen option: “Generated, name-constrained CA per profile”, because Name Constraints bound the damage if the key leaks (to non-public names, though the defaults cover every .test, .local, and .svc name, not just the project’s) and the whole flow needs no extra tool. Option 4 avoids putting the key in a Secret but makes the CA vanish with the cluster, so the trusted certificate would change on every recreate.
Details: rcgen (ring backend) with a 10-year lifetime, notBefore back-dated one hour, a 128-bit random serial, IsCa::Ca(BasicConstraints::Constrained(0)), KeyCertSign and CrlSign. Files live in gen-<random>/{ca.key,ca.crt} under ~/.local/share/xianyun/<profile>/pki/ and a relative current symlink is swapped atomically. ensure_current re-signs with the same key when constraints change and regenerates on expiry.
Consequences
Good:
- Atomic publish means readers never see a partial CA
- The key file is created
0600withcreate_new
Bad:
- The CA key is copied into a kustomize staging directory under
$TMPDIRwith mode644inside a755directory (measured at the backend on 2026-10-13; latent until the deployer registry gap is fixed) (TM-PKI-001) - The key is stored as a cluster Secret, and
PkiCaProvider::loadcan generate a CA with no Name Constraints on a profile that has none (TM-PKI-002) - With
driver: external, the cluster reports its own DNS zone and that value feeds the constraints without validation (TM-PKI-003) - Directories are created with the default umask, loading doesn’t verify that the key matches the certificate, and client enforcement of Name Constraints varies (TM-PKI-004, TM-PKI-005, TM-PKI-006)
- A 10-year lifetime is long for a certificate whose key lives in a cluster
Neutral:
- The common name is still “Caproni Dev CA”; see ADR-0003
Implementation Location
crates/xianyun-pki/src/ca.rscrates/xianyun-pki/src/constraints.rscrates/xianyun-pki/src/truststore/install.rscrates/xianyun-deploy/src/deployer/kustomize/camaterial.rs
More Information
- Retroactively documented on 2026-10-13 from the code and
crates/xianyun-pki/PORT_NOTES.md. - Related: FR-008, SR-010, SR-011, STORY-003.
ADR-0007: Pull-through registry cache, bring-your-own by default with an optional managed zot
Status: accepted · Date: 2026-10-13 · Decision makers: James Hebden
Context and Problem Statement
Recreating a local cluster re-pulls every image, which is slow and hits registry rate limits. A host-side pull-through cache that outlives the cluster fixes both. The Go project’s docs/design/registry-cache.md recorded twelve numbered decisions about this feature, and its decision log recorded two more (“BYO-only” and “start colima on containerd”) that were still marked Proposed and in part contradicted the design document. This ADR records the state the code is in, so the contradiction has one resolution.
Decision Drivers
- The cache has to survive cluster destroy and recreate
- Caproni shouldn’t own a host service it can’t make reliable on macOS, Linux, and rootless Docker
- Developers who already run a cache (zot, distribution, Harbor) should be able to point at it
- k3s only honours mirror configuration through containerd, not through the dockerd shim colima uses by default
Considered Options
- Host-side cache;
registry_mirror.managed: trueruns and supervises one zot, otherwise the user supplies anendpoint, withmanageddefaulting to false - Bring-your-own only, with no managed zot
- In-cluster cache (for example k3s embedded Spegel)
- Pre-import a fixed image set into k3s
Decision Outcome
Chosen option: “Host-side cache, bring-your-own by default, optional managed zot”, because it matches what the configuration reference documents (managed unset means bring your own) and what both the Go and Rust code implement. Option 3 resets with the cluster. Option 4 misses incidental pulls. Option 2 was the proposal in the Go project’s decision log (from gitlab-org/caproni!315); the managed-zot code path was kept in both implementations, so option 2 isn’t what shipped.
Managed mode details: one zot is shared across profiles with ref-count marker files, serialised by flock; up starts it, destroy releases the profile’s reference, and only registry stop stops it; it’s started detached with setsid, with a PID file and a log file. Drivers write the mirror config themselves: k3s registries.yaml for k3d, containerd hosts.toml inside the colima VM.
Consequences
Good:
- Warm caches across
destroyandupcycles - Developers with an existing cache aren’t forced onto a second one
Bad:
- zot listens on
0.0.0.0:5555and the generated config has no authentication section (TM-REG-001); whether anonymous writes are accepted is unproven - The PID file is parsed as
i32and signalled without a positivity or ownership check (TM-REG-002) - The k3d mirror configuration sets
insecureSkipVerify: true(TM-REG-003) - The Go project’s decision log rows for this feature were stale; they left with the Go documentation (ADR-0017)
Neutral:
driver: externalcan’t use a mirror, because Caproni can’t rewrite an unmanaged cluster’s container runtime
Implementation Location
crates/xianyun-deploy/src/registrycache/mod.rscrates/xianyun-deploy/src/registrycache/process.rscrates/xianyun-deploy/src/registrycache/config.rscrates/xianyun-cluster/src/driver/k3d.rscrates/xianyun-cluster/src/driver/colima.rs
More Information
- Retroactively documented on 2026-10-13 from the Go project’s registry-cache design (decisions 1 through 12) and configuration reference at the pinned upstream commit, and the merge history (
ms-registry-cache-byomerged to main). - The original twelve decisions are carried by this ADR and by ADR-0013 through ADR-0016.
- Related: FR-011, SR-012.
ADR-0008: Tiered NixOS VM tests with pinned image digests
Status: accepted · Date: 2026-10-02 · Decision makers: James Hebden
Context and Problem Statement
The Rust unit suite mocks the cluster driver throughout, so it never boots the compiled binary, never parses a real config from disk, and never goes through Cli::parse() to dispatch(). Two real bugs hid in that gap: a before_hook whose resolved config was silently discarded, and a kubectl::Client that carried a kubeconfig but never set KUBECONFIG. The project needed end-to-end coverage that’s reproducible and doesn’t depend on a developer’s Docker daemon or on network access during the build.
Decision Drivers
- Test the real binary through its real entry point
- Reproducible results from
nix build, with no network access in the Nix sandbox - A cheap tier that runs fast and a heavier tier that exercises a real cluster
- Pinned inputs, so a floating image tag can’t change a test result
Considered Options
- NixOS VM tests (
runNixOSTest) in two tiers, with the k3d images fetched as fixed-output derivations pinned by digest - Docker Compose based integration tests in CI
- Shell scripts under
integration-tests/run by hand or in CI, as the Go project did - Rely on the unit tests
Decision Outcome
Chosen option: “Two-tier NixOS VM tests with pinned digests”, because VM tests give a throwaway machine per run and fixed-output derivations let the Nix-sandboxed build load k3d images without reaching a registry. Option 3 exists for the Go binary and needs a Docker daemon and network.
Tier 1 (e2e-mock) boots a VM with only the xianyun binary and a cluster.driver: mock config, and drives version, config validate, config print, doctor, status, down, and destroy. Tier 2 (e2e-k3d) creates a real k3d cluster, checks it with kubectl get nodes, and tears it down. Checks are registered for x86_64-linux and aarch64-linux only.
Consequences
Good:
- Both bugs above were found by writing these tests
- Digest pinning makes Tier 2 reproducible
Bad:
- The first two tiers didn’t run
upwith a deployer, which is why the unregistered deployer registry went unnoticed (GAP-001 in the spec; measured 2026-10-13). Scenario tests built onnix/tests/lib.nixnow cover it (ADR-0019). - The repository has no CI job that runs
cargo test, the VM checks, orcargo audit:.gitlab-ci.ymlcontains nocargoornixinvocation (it includes a sharedstandard-buildtemplate whose scanner coverage ofCargo.lockwasn’t checked) (TM-SUP-001) - Tier 2 needs KVM and substantial memory, and there’s no
aarch64-darwinequivalent - The cluster-domain discovery Job still pulls
busybox:1.36by tag (TM-EXEC-004)
Neutral:
- The macOS build host runs the unit suite under daemonless single-user Nix; see macOS
Implementation Location
nix/tests/e2e-mock.nixnix/tests/e2e-k3d.nixbook/src/project/testing.mdflake.nix
More Information
- Retroactively documented on 2026-10-13. The date is that of
feat(nix): add NixOS VM tests; Tier 2 followed on 2026-10-12. - Related: NFR-001, NFR-007, SR-013.
ADR-0009: Version 2 configuration only, held as a JSON value and validated against an embedded schema
Status: accepted · Date: 2026-10-13 · Decision makers: James Hebden
Context and Problem Statement
The Go tool moved from a v1 to a v2 configuration format (repositories, deployers, reloaders) and kept migration code. The Rust port had to decide which versions to load, how to represent a config that is merged from several sources (a base file, a local overlay, extends: fragments, and project files from cloned repositories), and where validation happens relative to variable expansion.
Decision Drivers
- Deep-merging YAML from several sources, with some keys appending rather than replacing
- Rejecting bad config with errors that point at the field
- Schema as the single source of truth (it also drives the generated reference docs)
- Don’t carry a v1 loader nobody needs
Considered Options
- Parse into
serde_json::Value, merge with RFC 7386-style rules plus append-list keys, expand variables, validate against an embedded draft-07 JSON Schema, then deserialise into typed structs; v2 only - Deserialise straight into typed structs and merge the structs
- Keep v1 loading and run the migration at load time
Decision Outcome
Chosen option: “JSON value, embedded schema, v2 only”, because merging untyped values is simpler than merging typed structs with optional fields, and the same schema file that the Go tool ships (schema/config.v2.schema.json, schema/caproni-project.schema.json) can be embedded with include_str!. config migrate exists but can only report “already latest”, because only v2 exists.
Variable expansion is hand-written rather than a shell. In configuration values $VAR, ${VAR}, length, indirection, slices, replacement, and prefix and suffix removal are supported, and the :-, :?, :+, and := forms are rejected as unsupported. Inside a variables: definition ${VAR:-default}, ${VAR:?message}, and ${VAR:+alternate} are also supported. $(...), backticks, and arithmetic are preserved verbatim and never evaluated. Remote extends: fragments must be https and named xianyun*.yaml or caproni*.yaml, capped at 10 MiB.
Consequences
Good:
- One schema, shared with the Go tool and the docs
- No command substitution in config values
Bad:
- Expansion falls back to any process environment variable, and
config printshows the result (measured 2026-10-13) (TM-CFG-003) - Remote fragments have no pinning or signature, and relative references inside them may accept
http://(TM-CFG-001, TM-CFG-002) - A project file from a cloned edit-mode repository can add hooks (TM-CFG-004)
- Schema validation runs after expansion, and the schema is recompiled on every load
serde_yamlis deprecated, and alias expansion is unbounded (TM-SUP-002, TM-CFG-006)- The schema text still says the default static name is
caproni
Neutral:
- The persistent file cache for remote fragments is ported but not wired in, so
--no-cacheis currently a no-op
Implementation Location
crates/xianyun-core/src/config/caproni.rscrates/xianyun-core/src/config/merge.rscrates/xianyun-core/src/variables/expand.rscrates/xianyun-core/src/variables/resolver.rsschema/config.v2.schema.json
More Information
- Retroactively documented on 2026-10-13 from
crates/xianyun-core/PORT_NOTES.mdand the loader code. - Related: FR-001, FR-002, SR-006, SR-007, SR-008.
ADR-0010: mdBook and rustdoc for the Xianyun documentation
Status: accepted · Date: 2026-10-13 · Decision makers: James Hebden
Context and Problem Statement
The repository’s documentation site is a Hugo site that publishes the whole docs/ directory. That content describes the Go caproni binary, its commands are generated from the Go command tree, and its pages use caproni names and paths. Xianyun needed documentation that names the xianyun binary, treats the MCP server as the main way to use it, and includes API documentation for the Rust crates. A review on 2026-10-13 also found that publishing the new architecture, specification, and threat-model pages through Hugo would turn links into pages marked “never render” into links to a section index.
Decision Drivers
- The Rust API documentation should come from the Rust toolchain, not be written by hand
- The command reference shouldn’t drift from the binary
- The site should build in the same Nix development shell as the code
- The Go documentation should stay as it is until the Go binary is retired
- Light and dark themes and keyboard navigation without custom work
Considered Options
- A separate mdBook for Xianyun, with rustdoc output merged under
api/, and the Hugo site left for the Go docs - Rewrite the Hugo content for Xianyun
- Hugo for prose and a separately hosted rustdoc site
- Only rustdoc, with prose in crate-level documentation
Decision Outcome
Chosen option: “A separate mdBook with rustdoc merged under api/”, because mdBook is the standard prose companion to rustdoc, is packaged in nixpkgs, ships light and dark themes with search and keyboard navigation, and leaves the Go documentation untouched. Option 2 would mean rewriting pages for a binary the Hugo site’s other pages don’t describe, with the command reference generated from Go. Option 4 has no good home for task-oriented material such as connecting an agent.
The book lives in book/. The Xianyun-specific architecture decisions, specification, user stories, threat model, and the earlier Rust-rewrite notes moved into book/src/project/. The command reference is generated from xianyun --help by scripts/gen-cli-reference.sh, and scripts/build-docs.sh builds the book and the API reference together.
Consequences
Good:
- The API reference and the command reference are generated from the code
- Cross-references among ADRs, the specification, stories, and the threat model resolve inside one site
- A
nix build .#docsoutput can be built in CI
Bad:
- Two documentation systems live in one repository until the Go tool is retired
- The threat model is published with the rest of the book, so its open findings are visible wherever the book is hosted. Leaving it out of
SUMMARY.mdwould break the links from the ADRs and the specification. - Some pages that lived under
docs/(the Rust rewrite notes and the VM test page) moved, so links from the Hugo documentation to them were replaced with plain paths - Nothing deploys the book yet
Neutral:
- The repository’s Markdown linter now also covers
book/src
Implementation Location
book/book.tomlbook/src/SUMMARY.mdscripts/build-docs.shscripts/gen-cli-reference.shnix/docs.nix
More Information
- Recorded on 2026-10-13, in the same change that created the book.
- Related: ADR-0001.
- Amended by ADR-0017: the Hugo site this ADR left in place for the Go documentation was removed along with that documentation, and the book is now published through GitLab Pages.
ADR-0011: Colima on macOS and k3d on Linux as the local cluster drivers
Status: accepted · Date: 2026-10-13 · Decision makers: James Hebden
Context and Problem Statement
Xianyun needs a local Kubernetes cluster that comes up quickly, can be destroyed and recreated, and gives the host a usable network path to the ingress. The two platforms it targets differ: macOS has no native container runtime, and Linux does. The original Caproni project settled this in its alpha decision log; this ADR records the decision and what the Rust code does with it.
Decision Drivers
- A real route from the host to the cluster’s ingress on both platforms
- Lightweight enough to run beside a developer’s other work
- Works in CI on Linux runners
- No privileged setup beyond what the container runtime itself needs
Considered Options
- Colima on macOS and k3d on Linux, chosen automatically by
driver: auto - k3d everywhere, with Docker Desktop on macOS
- minikube everywhere
- Kind everywhere
Decision Outcome
Chosen option: “Colima on macOS and k3d on Linux”, because Colima gives macOS a Linux VM with its own network address without Docker Desktop, and k3d runs k3s in containers directly on Linux, including on CI runners. The original decision log noted that Colima lacks network support on Linux, and that minikube also works. driver: auto picks colima on macOS and k3d elsewhere (crates/xianyun-cluster/src/driver/auto.rs); k3d and colima can still be named explicitly, and external, vcluster, and mock sit alongside them.
Consequences
Good:
- One setting covers both platforms, and CI uses the Linux path
- The drivers sit behind one trait, so adding another is a new file and a registration
Bad:
- Two code paths to test. Only the k3d path has an end-to-end test (
e2e-k3d); the Colima driver is covered by unit tests against a mocked runner, and its live-cluster tests are ignored. - Rootless Podman on Linux needs extra setup; see Linux and Podman setup
Neutral:
- Colima always runs on the containerd runtime; see ADR-0016
Implementation Location
crates/xianyun-cluster/src/driver/auto.rscrates/xianyun-cluster/src/driver/k3d.rscrates/xianyun-cluster/src/driver/colima.rs
More Information
- Recorded on 2026-10-13 from the Go project’s alpha decision log (see ADR-0017 for where that log went) and the Rust driver code.
- Related: FR-004
ADR-0012: mirrord for edit mode, with Skaffold as the fallback
Status: accepted · Date: 2026-10-13 · Decision makers: James Hebden
Context and Problem Statement
Edit mode lets a developer run their local code against a cluster of production-mode services, so a change doesn’t need a container rebuild. Something has to connect the local process to the cluster: its network, its environment, and its files. The original Caproni project evaluated this in its alpha and chose a primary tool and a fallback.
Decision Drivers
- A change should take effect without rebuilding or redeploying a container
- The local process should see the same network and filesystem as the pod it stands in for
- Programs that mirrord can’t support still need a path
Considered Options
- mirrord as the primary reloader, Skaffold as the fallback
- Skaffold only
- Tilt
- Telepresence
Decision Outcome
Chosen option: “mirrord first, Skaffold as the fallback”, because mirrord runs the unmodified local process with the pod’s network, environment, and files, which is the fastest loop, while Skaffold covers cases where mirrord doesn’t work, such as programs that use mmap (the original example was Gitaly’s git processes). The original decision log noted that Tilt wasn’t evaluated, and this ADR doesn’t evaluate it or Telepresence either.
Both are reloader types in the configuration (mirror and skaffold), and xianyun run starts every configured reloader concurrently.
Consequences
Good:
- Two reloader types behind one registry-shaped interface, so a third can be added without changing
run runwaits for every reloader before returning, using aJoinSet
Bad:
- The mirror reloader’s processes can’t run yet: the internal process supervisor stops after its preflight checks (GAP-002), and
statusfails when a Ready cluster has reloaders rundoesn’t prepare or clean up mirror deployment copies, and doesn’t check preconditions (GAP-006)- Neither reloader is exposed over MCP, since
rundoesn’t fit a request and response call
Neutral:
- mirrord’s own agent runs in the cluster and wasn’t reviewed for the threat model
Implementation Location
crates/xianyun-deploy/src/reloader/mirror/mod.rscrates/xianyun-deploy/src/reloader/skaffold/mod.rscrates/xianyun-cli/src/commands/run.rs
More Information
- Recorded on 2026-10-13 from the Go project’s alpha decision log and the Rust reloader code.
- Related: FR-006, Edit mode.
ADR-0013: zot as the managed registry cache implementation
Status: accepted · Date: 2026-10-13 · Decision makers: James Hebden
Context and Problem Statement
ADR-0007 settles that a host-side pull-through cache exists and that Xianyun can run one. It leaves open which program the managed mode runs. The cache has to proxy several upstream registries from one process, and it has to be something Xianyun can start from a configuration file it writes.
Decision Drivers
- Several upstream registries behind one listener
- On-demand pull-through, so nothing has to be pre-seeded
- One binary with no daemon dependencies, so it’s easy to install and supervise
- Blob deduplication and garbage collection without extra work
Considered Options
- zot, using its
syncextension - Docker’s
registry:2 - crane
- A custom HTTP proxy
- Spegel
Decision Outcome
Chosen option: “zot”, because it’s a single statically linked binary whose sync extension supports multiple upstream proxies with on-demand pull-through, deduplicates blobs, and has built-in garbage collection. The alternatives fall short in specific ways: registry:2 has no multi-upstream sync, so it would need one instance per upstream; crane is a client tool and can’t proxy pulls; Spegel is peer-to-peer inside one cluster and keeps nothing across a destroy; a custom proxy would mean writing what zot already provides.
Xianyun writes zot’s configuration (crates/xianyun-deploy/src/registrycache/config.rs) and starts the binary found on PATH.
Consequences
Good:
- One process and one port serve every upstream
- The data directory persists across cluster destroy and recreate
Bad:
- zot has to be installed separately, and it’s resolved through
PATH(TM-EXEC-005) - The generated configuration binds
0.0.0.0:5555and has no access control (TM-REG-001)
Neutral:
- Bring-your-own mode isn’t tied to zot; it accepts any cache that speaks the registry protocol
Implementation Location
crates/xianyun-deploy/src/registrycache/config.rscrates/xianyun-deploy/src/registrycache/process.rs
More Information
- Recorded on 2026-10-13 from decision 2 in the Go project’s registry-cache design (see ADR-0017).
- Related: FR-011, Registry cache.
ADR-0014: Registry cache lifecycle: auto-start, never auto-stop, one shared instance
Status: accepted · Date: 2026-10-13 · Decision makers: James Hebden
Context and Problem Statement
A managed registry cache is a host process that outlives any single cluster. Three questions follow from that: who starts it, when it stops, and what happens when several projects on one machine each want it. The wrong answers cost either the warm cache (stopping too eagerly) or ports and disk (running one per project).
Decision Drivers
- The common loop is
destroythenup, often within one session, and it should find a warm cache - An idle cache costs almost nothing
- Several profiles on one host shouldn’t each claim a port and a data directory
- Coordination between separate
xianyunprocesses needs to be simple enough to trust
Considered Options
upstarts the cache if it isn’t running;destroyreleases the project’s claim and leaves it running; onlyregistry stopstops it; one instance serves every profile, tracked by marker files under a lock- Stop the cache when the last claim is released
- Stop the cache after an idle timeout
- One cache instance per profile
- Inter-process messaging (a socket or named pipe) for the claim count
Decision Outcome
Chosen option: “auto-start, never auto-stop, one shared instance”. The original design tried option 2 first and it broke straight away for single-cluster users, who destroy and recreate in one session and found a cold cache each time. A timer (option 3) adds complexity for little benefit. Per-profile instances (option 4) multiply ports and disk with no sharing between profiles. Marker files, one empty file per participating profile under the registry’s refs/ directory, behind an exclusive flock, are the simplest coordination that works across processes: the directory listing is the count and the file names are the profiles.
Consequences
Good:
- A warm cache across destroy and recreate
- Claims are visible with
ls, andxianyun registry statusreports them
Bad:
- Stopping is manual. A forgotten cache keeps running and keeps listening (TM-REG-001).
- A PID file left behind by a crash can point at an unrelated process, and
registry stopsignals it without checking (TM-REG-002)
Neutral:
- The cache is started detached, with a PID file and a log file next to its data
Implementation Location
crates/xianyun-deploy/src/registrycache/refs.rscrates/xianyun-deploy/src/registrycache/lock.rscrates/xianyun-deploy/src/registrycache/process.rs
More Information
- Recorded on 2026-10-13 from decisions 4 and 7 in the Go project’s registry-cache design.
- Related: ADR-0007, FR-011.
ADR-0015: The registry cache is opt-in and fails soft
Status: accepted · Date: 2026-10-13 · Decision makers: James Hebden
Context and Problem Statement
The cache is a performance feature. Turning it on adds a network listener, a host process, and a dependency on a zot binary. Two defaults need deciding: whether it’s on unless you say otherwise, and what up does when the cache can’t start.
Decision Drivers
- Existing projects shouldn’t change behaviour when they upgrade
- A missing binary or a port conflict shouldn’t block a developer from getting a cluster
- A failure still has to be diagnosable
Considered Options
- Off unless
cluster.registry_mirrorlists upstreams; on a failure to start, warn and bring the cluster up without the cache - On by default, with a flag to turn it off
- Off by default, and a failure to start the cache fails
up
Decision Outcome
Chosen option: “opt-in, and fail soft”. The cache is off when registry_mirror is absent or upstreams is empty. When it’s on and can’t start, up logs a warning (“registry cache could not be started; continuing without it”) and carries on, so pulls go to the upstream registries directly. The cache only affects speed, never correctness, so blocking bring-up on it would be the wrong trade. Turning it on by default would add a service to everyone’s up, with new ways to fail (a port conflict, a missing binary).
Consequences
Good:
- No change for projects that don’t configure it
- A broken cache never blocks
up
Bad:
- A project that relies on the cache for speed can lose it without noticing if nobody reads the warning
- In CI, a silent fall-back to the upstream registries can hit rate limits that the cache was meant to avoid
Neutral:
xianyun registry statusand thezot.logfile are how you diagnose a cache that didn’t start
Implementation Location
crates/xianyun-cli/src/commands/up.rscrates/xianyun-core/src/config/types.rs
More Information
- Recorded on 2026-10-13 from decisions 5 and 6 in the Go project’s registry-cache design.
- Related: ADR-0007, Registry cache.
ADR-0016: Containerd mirror wiring for the registry cache, and Colima on containerd
Status: accepted · Date: 2026-10-13 · Decision makers: James Hebden
Context and Problem Statement
For the cluster to pull through the cache, its container runtime has to be told which registries to mirror. The runtime both drivers share is containerd, but they reach it differently: k3d hands a file to k3s, which turns it into containerd configuration, while Colima’s VM already runs its own containerd. The first design configured dockerd’s registry-mirrors, which only ever mirrors Docker Hub, so images from other registries bypassed the cache.
Decision Drivers
- Mirror every upstream registry, not only Docker Hub
- Use the one mechanism both drivers share
- A driver shouldn’t depend on a host name that only some container setups provide
- Configuration that Xianyun fully owns should be written, not merged
Considered Options
- Write containerd’s per-registry
hosts.tomlfor both drivers, delivered the way each driver can accept it, and run Colima on containerd - dockerd
registry-mirrorsindaemon.json - k3s
registries.yamlin Colima - Run two containerd instances in the Colima VM
Decision Outcome
Chosen option: “containerd hosts.toml for both drivers, and Colima on containerd”.
- k3d passes
--registry-config <registries.yaml>, and k3s expands it into containerd configuration at node start. - Colima is always started with
--runtime containerd. Xianyun writes/etc/containerd/config.toml(withconfig_pathset to/etc/containerd/certs.d) and one/etc/containerd/certs.d/<upstream>/hosts.tomlper upstream over SSH, then restarts containerd and k3s so they re-read it. The files are overwritten in full on everyup, which is simpler than merging and avoids duplicate entries, because Xianyun owns that directory and the shippedconfig.tomlhas nothing else to preserve. - The two drivers reach the host cache by different names: Colima uses
host.docker.internaland k3d useshost.k3d.internal. The shared code carries only the port.host.docker.internalis a Docker Desktop and Colima contract that rootless Docker doesn’t provide, so a shared name would let k3d pulls silently skip the cache. - Nothing is removed on
destroy. The configuration lives inside the VM, and deleting the VM deletes it.
Option 2 shipped first and mirrored Docker Hub only. Option 3 doesn’t work on Colima because its k3s uses the VM’s external containerd, so registries.yaml is never rendered into it. Option 4 doubles the footprint for no gain.
Consequences
Good:
- Every upstream in
registry_mirror.upstreamsis mirrored - Re-running
upconverges to the same configuration
Bad:
- A Colima profile no longer exposes a Docker socket, so
docker context useagainst it stops working fordocker buildanddocker run. Usenerdctlor a separate Colima profile. - The k3d mirror configuration sets
insecureSkipVerify: truefor the endpoint (TM-REG-003) - The Colima path has unit tests against a mocked runner but no end-to-end test
Neutral:
- Documented as shipped behaviour: the config reference says Xianyun always starts Colima on containerd
Implementation Location
crates/xianyun-cluster/src/driver/colima.rscrates/xianyun-cluster/src/driver/k3d.rscrates/xianyun-cli/src/commands/up.rs
More Information
- Recorded on 2026-10-13 from decisions 9 through 12 in the Go project’s registry-cache design, checked against the Rust drivers.
- Related: ADR-0007, ADR-0011.
ADR-0017: Remove the Go implementation, and publish the documentation through GitLab Pages
Status: accepted · Date: 2026-10-13 · Decision makers: James Hebden
Context and Problem Statement
For two weeks this repository held two implementations of one tool: the Go program it forked from (368 source files under cmd/ and internal/, plus its documentation, Hugo site, integration tests, and CI) and the Rust workspace that replaces it. The project is meant to be public, and the Go side was the wrong thing to show. Its documentation named the wrong binary, its CI exercised the wrong program, and its configuration pointed at GitLab-internal templates and chat channels. The Rust workspace had also ported every Go behaviour it intended to port, and its parity tests were already in crates/.
The documentation needed a public home. The Hugo site that published the Go documentation had been replaced by the mdBook in ADR-0010, and nothing published it.
Decision Drivers
- A public repository should describe the program it contains
- The Go behaviour is the specification the Rust code was ported against, so it has to remain findable
- No scenario coverage should vanish silently
- The pipeline should check what’s in the tree: Rust, the examples, the book, and the dependencies
- Documentation errors should fail a merge request, not a deployment
Considered Options
- Remove the Go source, documentation, tooling, and CI; keep the Rust workspace, the schema, and the examples; pin an upstream commit as the reference; publish the book through a GitLab Pages job
- Keep the Go source in the tree as a parity reference, and remove only its documentation and CI
- Move the Go source to a branch or a second repository
- Keep everything and mark the Go side as legacy
Decision Outcome
Chosen option: “Remove everything Go, pin an upstream commit, publish through GitLab Pages”. The Go source is already in the project’s history and in the upstream repository, which is public; keeping a second copy in the working tree would mean carrying code nothing builds or tests. Upstream commit fa1a2d1e is the last state before the Rust workspace was added, so it’s the reference for every “Ported from” comment and PORT_NOTES.md entry. Options 2 and 4 keep the problem; option 3 adds a place to maintain.
What was removed: cmd/, internal/, main.go, the Go module files and tooling configuration (golangci-lint, goreleaser, mockery, semantic-release), the Dockerfiles, the Hugo site and the docs/ it published, and the GitLab-internal template and review-automation configuration (Copier answers, Renovate presets, Duo instructions, and the shared CI components).
What was kept: the Rust workspace, schema/ (embedded by xianyun-core), examples/ (validated in CI), the Nix flake and VM tests, and the scripts for the documentation.
The content in the removed documentation that still applies to Xianyun was rewritten into the book, and each page was checked against the binary: composition, lifecycle hooks, Linux and Podman setup, troubleshooting and resets, use cases, and the architecture page. The decisions in the old decision log and in the registry-cache design became ADR-0011 through ADR-0016.
The pipeline is in .gitlab-ci.yml. Every job runs inside nix develop, so the toolchain is the one pinned by flake.lock. The docs job builds the whole site (regenerated command reference, mdBook, link check, rustdoc with warnings denied) on merge requests and on the default branch and keeps it as an artifact. The pages job runs on the default branch only and publishes that artifact. That split follows the pattern in GitLab’s hardened-builds project: the site is built and checked before anything is deployed, and the deploy job only moves files into place. A scheduled pipeline runs cargo deny alone, because advisories appear without a code change.
Consequences
Good:
- The repository contains one implementation, and its documentation describes it
- A broken link, a stale command reference, or a rustdoc warning fails the merge request
- Dependency policy is checked:
deny.tomlenforces licenses, advisories, and sources, and records the two accepted advisories with reasons
Bad:
- The Go integration suites are gone. They drove the Go binary against real clusters and covered scenarios with no Rust equivalent yet: dev CA drift, multiple environments from sibling directories, the mirrord reloader (with and without a project file), the registry cache in bring-your-own mode, bundled ingress, and basic bring-up. Deployers can’t run in Xianyun yet (GAP-001), so those suites couldn’t pass against it anyway. Each scenario is recorded in the specification’s backlog and is to be rebuilt as a NixOS VM test. The eighth suite, which validated every example configuration, needs no cluster and was replaced by
scripts/validate-examples.sh. - The pipeline’s end-to-end coverage is thinner than the Go project’s: the unit suite, the example validation, and two NixOS VM tests, one of which needs KVM and runs manually
- Anyone who wants the Go source has to look it up upstream. The pinned commit is linked from the README and
AGENTS.md. - Several
PORT_NOTES.mdfiles still mention Go test names and file paths that are no longer in the tree
Neutral:
- The Hugo site is gone, along with its theme submodule. Pages for GitLab’s published documentation moved to the book.
- Publishing is by GitLab Pages, so the site’s address depends on the project path
Implementation Location
.gitlab-ci.ymlscripts/build-docs.shscripts/validate-examples.shdeny.tomlbook/book.toml
More Information
- Recorded on 2026-10-13, in the same change that removed the Go tree.
- Update 2026-10-14: deployers now work (GAP-001 closed), and five of the seven scenarios were rebuilt as NixOS VM tests (ADR-0019). The two that need edit mode remain.
- Related: ADR-0002, ADR-0010, Building the documentation.
ADR-0018: Xianyun collects no telemetry
Status: accepted · Date: 2026-10-13 · Decision makers: James Hebden
Context and Problem Statement
The Go tool Xianyun forked from sent usage events to a GitLab-run collector. The Rust port carried over the pieces of that design (event types for commands, hooks, and edit-mode runs, an opt-out through environment variables, a user ID derived from the hostname and home directory) but never finished it: the emitter was a placeholder, and nothing outside the module called the tracker. It also decided whether to switch itself on by looking for an @gitlab.com address in git config, a GitLab Zoom configuration file, or a GitLab hostname pattern.
The project is becoming public. Dormant code that describes sending command arguments and hook scripts to a collector, and that special-cases one company’s employees, tells readers something false about the tool and invites someone to finish it.
Decision Drivers
- A public tool should say plainly what leaves the machine, and “nothing” is the easiest claim to keep true
- Hook scripts and command arguments can contain secrets, so an event carrying them is a liability (TM-TEL-001)
- The design rules say Xianyun knows nothing about any one application, and a GitLab-employee heuristic breaks that
- Dormant code still has to be read, tested, and maintained
Considered Options
- Remove the telemetry code, its environment variables, and its documentation
- Finish the emitter and ship it opt-in
- Keep the dormant code and document that it does nothing
Decision Outcome
Chosen option: “Remove it”. There’s no user need for it in this project, and removing it makes the strongest privacy statement available. Option 2 means designing what’s collected and where it goes, with no one asking for the result. Option 3 keeps the problems above and adds a promise to keep it dormant.
Removed: crates/xianyun-executor/src/telemetry/ (events, team detection, user ID, and the client), the XIANYUN_TELEMETRY, XIANYUN_TELEMETRY_URI, and CAPRONI_TELEMETRY* variables, and the two tests that existed only for it. The in-crate SHA-256 stays, because the file cache uses it for cache keys.
Consequences
Good:
- The claim “Xianyun collects no usage data” is true and checkable by searching the code
- TM-TEL-001 is eliminated, and the GitLab-employee heuristic is gone
Bad:
- The maintainers get no usage data, so decisions about which commands matter rest on feedback and issues
- The removed code took 27 tests with it (12 for events, 8 for the client, 5 for team detection, 2 for the user ID), measured by counting the test attributes in the deleted files. They covered only the removed code.
Neutral:
- A future change that adds telemetry needs a new ADR that supersedes this one, and a new threat model entry
Implementation Location
crates/xianyun-executor/src/lib.rscrates/xianyun-executor/src/sha256.rs
More Information
ADR-0019: NixOS VM scenario tests for the end-to-end behaviour
Status: accepted · Date: 2026-10-14 · Decision makers: James Hebden
Context and Problem Statement
ADR-0008 set up two tiers of NixOS VM tests: one with the mock driver and one with a real k3d cluster and no deployers. That left the behaviour the Go project’s integration suites covered with nothing behind it: installing deployers, ordering them, a cluster without Traefik, CA re-signing, several environments on one host, and a registry cache. Those suites were removed with the Go tree (ADR-0017). The gap was real: with no test that ran up with a deployer, no deployer manager was registered in production and nothing noticed.
Decision Drivers
- Test the compiled binary, through its real entry point, against a real cluster
- Reproducible with
nix build, with no network in the sandbox - One behaviour per test, so a failure points at a cause
- Shared setup in one place rather than copied into every test
- A test must be able to fail for the right reason
Considered Options
- One NixOS VM test per scenario, sharing a library (
nix/tests/lib.nix) for the VM definition and the Python helpers - One large test that runs every scenario in one VM
- Shell suites like the Go project’s, run in CI against a Docker-in-Docker daemon
- Rust integration tests that start k3d from
cargo test
Decision Outcome
Chosen option: “One VM test per scenario with a shared library”. A single VM (option 2) would run for most of an hour, and a failure early on would hide every later scenario. Shell suites (option 3) need a Docker daemon and a network on the runner, which is what ADR-0008 set out to avoid. Rust tests that start k3d (option 4) can’t be hermetic.
Seven checks now exist (e2e-mock, e2e-k3d, and e2e-deploy, e2e-no-ingress, e2e-ca-drift, e2e-cells, e2e-registry-mirror). The five new ones use nix/tests/lib.nix: the VM (Docker, k3d, kubectl, helm, kustomize, openssl, jq), a prelude that loads the pinned images, and helpers that run xianyun and kubectl and report each command’s exit status, duration, and warnings. e2e-k3d predates the library and keeps its own copy. One more image, zot, is pinned in nix/images/zot.nix.
Workloads are ConfigMaps, because the VM can’t pull an application image. Each scenario checks its result with a tool other than Xianyun (kubectl, helm, openssl, docker inspect), so a bug in Xianyun’s own reporting can’t pass the test.
Consequences
Good:
- Five scenarios from the Go suites are covered again, and each ran to a pass against the final code on 2026-10-14
- Writing them found a default-logging bug and a 120-second discovery wait (GAP-010 and GAP-009)
- Controls are part of the tests:
e2e-no-ingressfirst shows that k3s does deploy Traefik by default, ande2e-registry-mirrorfirst shows that an uncached pull fails
Bad:
- Each test takes several minutes and needs KVM, so CI runs them manually and allowed to fail until a runner provides it
- The
builtin:dev-caandbuiltin:traefik-tlsstoreoverlays aren’t covered, since cert-manager’s CRDs and Traefik can’t be pulled - Two Go scenarios (the mirrord reloader, with and without a project file) have no test, because the edit-mode process supervisor stops after its preflight checks (GAP-002)
- The scenarios differ from the Go suites in places: no application serves a request, and managed zot isn’t exercised
- The shared library is a second place that has to track
e2e-k3d’s setup until that test is moved onto it
Neutral:
- Only
x86_64-linuxwas run. Theaarch64-linuxvariants evaluate but weren’t built.
Implementation Location
nix/tests/lib.nixnix/tests/e2e-deploy.nixnix/tests/e2e-no-ingress.nixnix/tests/e2e-ca-drift.nixnix/tests/e2e-cells.nixnix/tests/e2e-registry-mirror.nixnix/images/zot.nix
More Information
Xianyun specification
Version: 0.1 (retroactive)
Date: 2026-10-13
Scope: the Rust workspace under crates/ (xianyun and xianyun-mcp)
The key words “MUST”, “MUST NOT”, “REQUIRED”, “SHALL”, “SHALL NOT”, “SHOULD”, “SHOULD NOT”, “RECOMMENDED”, “MAY”, and “OPTIONAL” in this document are to be interpreted as described in RFC 2119.
Purpose and method
This is a retroactive specification. It was written from the Rust source, the Go tests the Rust port mirrors, and the existing design documents, then checked against a build of main at commit d5f74650. It describes what Xianyun is meant to do and marks where the code doesn’t yet do it.
Requirements use EARS patterns. Each functional requirement has a status:
- Implemented: code and at least one test exist, the behaviour was seen working or the tests pass, and every SHALL under the requirement is met. An unmet SHOULD is noted next to the status.
- Partial: some of the requirement works, or a SHALL under it isn’t met; the gap is named.
- Gap: the requirement is intended and the code doesn’t meet it. Each gap has a
GAP-identifier.
Every statement of status here comes from one of three sources: a test run (cargo test --workspace: 1,119 passed, 0 failed, 23 ignored on Linux, 2026-10-13; one of the ignored tests, compile_schema_lazy_loading, is an unimplemented! stub rather than a documented live-environment test), a measurement named in the threat model, or reading the code. The test run doesn’t prove a requirement; it proves the tests that exist pass.
Not examined
The Go implementation, the behaviour of the external tools Xianyun wraps, the macOS-only Keychain code (read, not run), and the contents of user-supplied Helm charts.
Related documents
- Threat model
- ADR-0001 through ADR-0009
- Stories
- Caproni’s design documents: the Go-era design this project ported, at the pinned upstream commit
Definitions
| Term | Meaning |
|---|---|
| Profile | A named environment; selects cluster name, data directory, and CA |
| Driver | The cluster backend: auto, k3d, colima, vcluster, external, or mock |
| Deployer | A configured unit that installs something into the cluster: helm, kustomize, or namespace |
| Reloader | A configured edit-mode process manager: mirror (mirrord) or skaffold |
| Hook | A command in lifecycle_hooks run before or around edit mode |
| Gated tool | An MCP tool that needs an elicitation answer before it runs |
Known gaps
| ID | Gap | Evidence |
|---|---|---|
| GAP-001 | Closed 2026-10-14. No deployer manager was registered in production, so up and deploy couldn’t install anything. | Before: deployer::registry::list_types() returned []. Fix: register_deployers() in crates/xianyun-cli/src/drivers.rs, with crates/xianyun-cli/tests/deploy_registry.rs and the e2e-deploy NixOS VM test, which installs a namespace, a Kustomize overlay, and a Helm chart on a real k3d cluster. |
| GAP-002 | edit-mode-process-supervisor always fails after preflight, and status returns an error when a Ready cluster has reloaders. | CODE: editmodeprocesssupervisor.rs:362-385, status.rs:51-54. |
| GAP-003 | doctor runs 2 of the Go tool’s 5 diagnostics (cluster domain and orphaned mirror copies). | CODE: diagnostics::builtin::all(); the Go diagnostics for disk space, macOS firewall, and node health aren’t ported and no PORT_NOTES entry records the omission. |
| GAP-004 | Closed 2026-10-13. Telemetry was dormant code that nothing called; it was removed rather than finished (ADR-0018). | The module and its environment variables are gone. |
| GAP-005 | --no-cache and the persistent remote-fragment cache are unwired. | CODE: no_caching is assigned at hooks.rs:64 and never read. |
| GAP-006 | run doesn’t prepare or clean up mirror deployment copies or check preconditions. | CODE: run.rs, collect_mirror_namespaces is ported and unused. |
| GAP-007 | up doesn’t validate /etc/hosts before proceeding. | CODE: up.rs:118 notes it as not ported “because hosts is a stub”; hosts::Client is now real. |
| GAP-008 | User-facing strings still say Caproni, and ca inspect prints raw Rust Debug output for its validity dates. | MEASURED 2026-10-13: xianyun version prints caproni version 0.1.0; xianyun --help says “Start the Caproni cluster” and “search … for caproni.yaml”; xianyun ca inspect printed Not Before: Some(SystemTime { tv_sec: 1791871947, tv_nsec: 0 }). |
| GAP-009 | Cluster DNS-domain discovery runs a busybox:1.36 probe Job first, and waits up to 120 seconds for it. On a cluster that can’t pull the image, every up, deploy, and ca inspect pays the full wait. | MEASURED 2026-10-14 in the NixOS VMs, which can’t pull: up on an already-running cluster took 122 seconds, deploy 123, deploy --repo 122. defaults() in crates/xianyun-cluster/src/clusterdomain/mod.rs lists the resolv.conf probe before the Corefile read, which needs no image. The order was ported from the Go tool. |
| GAP-010 | Closed 2026-10-14. A command run with no logging flags logged nothing. | MEASURED: LoggingArgs::default() held empty strings, so the default logger failed to build and dispatch discarded the error. Fixed in crates/xianyun-executor/src/logging/cliflags.rs and crates/xianyun-cli/src/commands/mod.rs; found by the e2e-deploy scenario. |
Functional requirements
FR-001: Configuration loading and compatibility
Status: Partial (the FR-001.1 stop rule isn’t met outside the home directory) Rationale: ADR-0003, ADR-0009
- FR-001.1 When a command needs configuration, the system SHALL search from the working directory upward for
xianyun.yaml, and SHALL fall back tocaproni.yamlin the same directory when noxianyun.yamlexists there. The search SHALL stop at the user’s home directory. (Not met from a working directory outside it, where the search continues to the filesystem root; see TM-CFG-007.) - FR-001.2 Where both
xianyun.yamlandcaproni.yamlexist in one directory, the system SHALL use thexianyun.*file. - FR-001.3 The system SHALL merge
xianyun.local.yaml(orcaproni.local.yaml),extends:fragments, andxianyun-project.yaml(orcaproni-project.yaml) from edit-mode repositories into the base configuration, appending list values forlifecycle_hooks,additional_hostnames, anddynamic_overrides. - FR-001.4 If a configuration fails validation against the embedded version 2 schema, then the system SHALL exit non-zero with a message naming the failing field as a JSON pointer (measured 2026-10-13:
/cluster/driver: "nonsense" is not one of [...]). Acluster.networkblock is required.
Feature: Configuration loading
Scenario: A Caproni project loads unmodified
Given a directory containing only "caproni.yaml" with "version: 2", "cluster.driver: mock", and a "cluster.network" block
When the user runs "xianyun config validate"
Then the exit code MUST be 0
Scenario: The primary name wins
Given "xianyun.yaml" sets hostname "a.test" and "caproni.yaml" sets hostname "b.test"
When the user runs "xianyun config print"
Then the output MUST contain "a.test"
And the output MUST NOT contain "b.test"
Scenario: An invalid field is named
Given a config with "cluster.driver: nonsense"
When the user runs "xianyun config validate"
Then the exit code MUST be non-zero
And the message MUST name the JSON pointer "/cluster/driver"
Tests: crates/xianyun-core/src/config/caproni.rs (unit), crates/xianyun-cli/tests/config.rs, nix/tests/e2e-mock.nix.
FR-002: Variable expansion
Status: Implemented
- FR-002.1 The system SHALL expand
$VARand${VAR}in configuration values, and SHALL also support length (${#VAR}), indirection (${!VAR}), slices, replacement, and prefix and suffix removal. In configuration values it SHALL reject the default, required, alternate, and assignment forms (:-,:?,:+,:=) with an unsupported-expansion error. - FR-002.1a In a
variables:definition the system SHALL additionally support${VAR:-default},${VAR:?message}, and${VAR:+alternate}. - FR-002.2 The system SHALL provide
XIANYUN_CLUSTER_NAME,XIANYUN_PRIMARY_HOSTNAME, andXIANYUN_CLUSTER_DOMAIN, and SHALL treat theCAPRONI_equivalents as aliases. - FR-002.3 The system SHALL NOT evaluate command substitution, backticks, or arithmetic expansion; those sequences SHALL be preserved as written.
- FR-002.5 The system SHALL treat a backslash before a dollar sign (
\$HOME) as the literal text. - FR-002.4 If a variable is unset and has no default or error operator, then the system SHALL fail validation for fields that reject unresolved variables (
cluster.driver,k3s.disable, repositoryref).
Feature: Variable expansion
Scenario: A predefined alias expands
Given a config value "${CAPRONI_CLUSTER_NAME}"
When the config is loaded with profile "dev"
Then the value MUST equal the value of "${XIANYUN_CLUSTER_NAME}"
Scenario: Command substitution is not evaluated
Given a config value "$(touch /tmp/x)"
When the config is loaded
Then the value MUST remain "$(touch /tmp/x)"
And no file "/tmp/x" MUST exist
Scenario: Defaults belong in variable definitions
Given a config value "${HOST:-fallback}" outside the "variables:" block
When the config is loaded
Then loading MUST fail with an unsupported-expansion error
Tests: crates/xianyun-core/src/variables/expand.rs (including expand_string_rejects_unsupported_expansions), resolver.rs, predefined.rs (unit).
Security: see SR-007.
FR-003: Configuration commands
Status: Implemented (migrate can only report “already latest”)
- FR-003.1 When the user runs
config validate, the system SHALL load and validate the configuration and print a summary, exiting 0 on success. - FR-003.2 When the user runs
config print, the system SHALL print the expanded configuration. - FR-003.3 When the user runs
config migrate, the system SHALL report “already latest” for version 2 configurations and SHALL write a backup before changing a file unless--skip-backupis given.
Feature: Configuration commands
Scenario: Print echoes the driver
Given a valid mock-driver config
When the user runs "xianyun config print"
Then the output MUST contain "mock"
Tests: crates/xianyun-cli/tests/config.rs, nix/tests/e2e-mock.nix.
FR-004: Cluster lifecycle
Status: Partial (status fails with reloaders, GAP-002; up is slow when the cluster can’t pull images, GAP-009)
Rationale: ADR-0008
- FR-004.1 When the user runs
up, the system SHALL start the cluster through the configured driver, fetch its kubeconfig, wait until the API server answers, and then run the deploy sequence (FR-005). - FR-004.2 When the user runs
down, the system SHALL stop the cluster and SHALL exit 0 when none was running. - FR-004.3 When the user runs
destroy, the system SHALL delete the cluster and its state and SHALL exit 0 when none exists. - FR-004.4 When the user runs
status, the system SHALL report the driver state. - FR-004.5 If the driver is
external, thendownanddestroySHALL return an error stating that Xianyun doesn’t own the cluster’s lifecycle. - FR-004.6 Where the driver is
auto, the system SHALL selectk3don Linux andcolimaelsewhere.
Feature: Cluster lifecycle
Scenario: Down and destroy are idempotent
Given a mock-driver config and a cluster that was never started
When the user runs "xianyun down" and then "xianyun destroy"
Then both exit codes MUST be 0
Scenario: Real k3d lifecycle
Given a k3d-driver config and docker available
When the user runs "xianyun up"
Then "kubectl get nodes" MUST report a Ready node
When the user runs "xianyun destroy"
Then no k3d cluster for the profile MUST remain
Tests: crates/xianyun-cli/tests/up.rs (16 ignored live-cluster tests across up, down, destroy, status, update, deploy; a 17th ignored test in up.rs covers cancellation), crates/xianyun-cluster/tests/*_driver.rs, nix/tests/e2e-mock.nix, nix/tests/e2e-k3d.nix, nix/tests/e2e-deploy.nix, nix/tests/e2e-cells.nix.
FR-005: Deployer orchestration
Status: Partial (the builtin: overlays of FR-005.5 haven’t been deployed end to end)
- FR-005.1 When
upordeployruns, the system SHALL deploy each configured deployer after itsneedsdependencies, in topological order. - FR-005.2 The system SHALL support deployer types
helm,kustomize, andnamespace. - FR-005.3 If a deployer type has no registered manager, then the system SHALL fail with an error naming the type.
- FR-005.4 When
deploy --repo <name>is given, the system SHALL deploy only the named deployer. (Despite its name the flag takes a deployer name; thee2e-deployscenario confirms this.) - FR-005.5 The
builtin:dev-caandbuiltin:traefik-tlsstorekustomize targets SHALL be available without the user supplying overlay files. (Not exercised end to end: they need cert-manager’s CRDs and a Traefik, which the VM tests can’t pull.) - FR-005.6 If a deployer fails, then the system SHALL stop, exit non-zero, and name that deployer.
Feature: Deployer orchestration
Scenario: A namespace deployer creates its namespace
Given a running cluster and a config with a "namespace" deployer for "demo"
When the user runs "xianyun deploy"
Then "kubectl get namespace demo" MUST succeed
Scenario: Dependencies run first
Given deployer "b" with needs ["a"]
When the user runs "xianyun deploy"
Then "a" MUST complete before "b" starts
Scenario: A broken deployer is named
Given a helm deployer "broken-chart" whose chart doesn't exist
When the user runs "xianyun deploy"
Then the exit code MUST be non-zero
And the output MUST name "broken-chart"
Tests: crates/xianyun-deploy/src/deploy/orchestrator.rs and the three managers’ unit tests; crates/xianyun-cli/tests/deploy_registry.rs (the registry is populated in production); the e2e-deploy NixOS VM test, which runs all three scenarios above against a real k3d cluster, and also repeats a deploy (the Helm upgrade path) and checks --repo.
FR-006: Edit mode and reloaders
Status: Partial (GAP-002, GAP-006)
- FR-006.1 When the user runs
run, the system SHALL start every configured reloader concurrently and SHALL not return success until each has finished. - FR-006.2 When the process receives an interrupt or termination signal, the system SHALL stop waiting for reloaders and SHALL run stop hooks.
- FR-006.3 The system SHALL support a
mirrorreloader (mirrord) and askaffoldreloader. - FR-006.4 Where a mirror reloader lists
edit_mode_processes, the system SHALL run each through the process supervisor.
Feature: Edit mode
Scenario: Reloaders are joined before exit
Given two reloaders that each take one second
When the user runs "xianyun run" and sends an interrupt
Then the process MUST NOT exit until both tasks have returned or been abandoned deliberately
Tests: crates/xianyun-cli/tests/run_concurrency.rs, run.rs, editmodeprocesssupervisor.rs.
FR-007: Repository update
Status: Implemented
- FR-007.1 When the user runs
update, the system SHALL clone missing repositories, fetch existing ones, and fast-forward only. - FR-007.2 Where
--verify-only(alias--dry-run) is given, the system SHALL report what would change and SHALL NOT modify any repository. - FR-007.3 Where
--stashis given, the system SHALL stash local changes before updating. - FR-007.4 If a fast-forward isn’t possible, then the system SHALL report the repository as not updated and SHALL leave it unchanged.
Feature: Repository update
Scenario: Dry run changes nothing
Given a clone that is behind its upstream
When the user runs "xianyun update --dry-run"
Then the clone's HEAD MUST be unchanged
Tests: crates/xianyun-update/src/updater.rs (unit), crates/xianyun-cli/tests/update.rs.
FR-008: Dev CA generation and trust
Status: Partial (FR-008.6 not met) Rationale: ADR-0006
- FR-008.1 When a command needs the CA, the system SHALL load the current CA for the profile or generate an ECDSA P-256 CA when none exists.
- FR-008.2 The system SHALL limit the CA with Name Constraints: always the default subtrees
.local,.test, and.svc, plus the configured hostname and the cluster domain when neither falls under a default (see SR-011). Measured 2026-10-13: a config with hostnamedemo.testand cluster domaincluster.localproduced[.local .test .svc]. - FR-008.3 When the user runs
ca trust, the system SHALL install the CA certificate in the system trust store and, wherecertutilis available, in NSS databases. - FR-008.4 When the user runs
ca untrust, the system SHALL remove the certificate, including entries installed under the legacy Caproni name. - FR-008.5 When the constraints change, the system SHALL re-sign with the same key; if the CA has expired, then the system SHALL generate a new one.
- FR-008.6 When loading a CA, the system SHALL verify that the certificate’s public key matches the private key. (Not met today; see TM-PKI-005.)
Feature: Dev CA
Scenario: A CA is generated once
Given no CA exists for profile "dev"
When the user runs "xianyun ca inspect" twice
Then both runs MUST report the same certificate fingerprint
Scenario: The CA is constrained
Given a config with hostname "gitlab.dev" and no cluster domain
When the CA is generated
Then its permitted DNS subtrees MUST be ".local", ".test", ".svc", and "gitlab.dev"
And a config with hostname "demo.test" MUST add no hostname entry, because ".test" already covers it
Tests: crates/xianyun-pki/src/ca.rs, constraints.rs, truststore/* (unit, mocked CommandRunner), crates/xianyun-cli/tests/ca.rs.
Security: SR-010, SR-011; TM-PKI-001 through TM-PKI-006.
FR-009: /etc/hosts management
Status: Implemented (SHOULD FR-009.5 not met) Rationale: ADR-0004
- FR-009.1 When the user runs
update-etc-hosts, the system SHALL compute the new file content and write it only if a hostname is missing or points elsewhere. - FR-009.2 Where
--dry-runis given, the system SHALL NOT write the file. - FR-009.3 Where
--removeis given, the system SHALL remove the configured hostnames. - FR-009.4 The system SHALL write
/etc/hostswith elevated privileges only when the running user isn’t root. - FR-009.5 The system SHOULD replace the file atomically and keep a backup. (Not met today; see TM-HOSTS-002.)
Feature: /etc/hosts management
Scenario: Dry run writes nothing
Given a hostname missing from /etc/hosts
When the user runs "xianyun update-etc-hosts --dry-run"
Then /etc/hosts MUST be unchanged
Tests: crates/xianyun-backends/src/hosts/mod.rs, crates/xianyun-cli/tests/updateetchosts.rs.
Security: SR-004.
FR-010: Kube context management
Status: Partial (FR-010.5 not met for the backup)
- FR-010.1 When the user runs
set-kube-context, the system SHALL merge the profile’s kubeconfig into the target file, preserving unrelated entries. - FR-010.2 The system SHALL write the kubeconfig atomically through a temporary file in the same directory.
- FR-010.3 Where
--useis given, the system SHALL make the profile’s context current. - FR-010.4 Where
--removeis given, the system SHALL remove the profile’s entries. - FR-010.5 The system SHALL create the kubeconfig and its backup readable only by the owner (see SR-010).
Feature: Kube context
Scenario: The backup is private
Given an existing kubeconfig with mode 0600
When the user runs "xianyun set-kube-context"
Then the backup file's mode MUST be 0600
Tests: crates/xianyun-cluster/tests/kubeconfig_file.rs, kubeconfig_merge.rs, crates/xianyun-cli/tests/setkubecontext.rs. The backup-mode scenario currently fails (measured: 0644).
FR-011: Registry cache lifecycle
Status: Partial (FR-011.6 not met) Rationale: ADR-0007
- FR-011.1 Where
registry_mirror.managedis true, the system SHALL start one zot instance onup, shared across profiles with reference counting. - FR-011.2 When
destroyruns, the system SHALL release the profile’s reference and SHALL NOT stop zot. - FR-011.3 When the user runs
registry stop, the system SHALL stop zot. - FR-011.4 Where
registry_mirror.managedis false and anendpointis set, the system SHALL write only the driver-side mirror configuration and SHALL NOT start any process. - FR-011.5 When the user runs
registry status -o json, the system SHALL print machine-readable status. - FR-011.6 Before signalling a PID from the PID file, the system SHALL verify the value is greater than 1. (Not met today; see TM-REG-002.)
Feature: Registry cache
Scenario: Destroy keeps the cache
Given a managed zot started by profile "a"
When the user runs "xianyun destroy" for profile "a"
Then zot MUST still be running
Tests: crates/xianyun-deploy/src/registrycache/* (unit), crates/xianyun-cli/tests/registry.rs.
FR-012: Doctor diagnostics
Status: Partial (GAP-003)
- FR-012.1 When the user runs
doctor, the system SHALL run each registered diagnostic and report pass or fail with a reason. - FR-012.2 The system SHALL provide diagnostics for cluster domain and orphaned mirror copies.
- FR-012.3 The system SHOULD provide diagnostics for disk space, macOS firewall, and node health, matching the Go tool.
Feature: Doctor
Scenario: Doctor does not panic with tools missing
Given a machine with none of helm, kustomize, git, k3d, or docker
When the user runs "xianyun doctor"
Then the output MUST NOT contain "panicked at"
Tests: crates/xianyun-cli/tests/doctor.rs, nix/tests/e2e-mock.nix.
FR-013: Lifecycle hooks
Status: Partial (FR-013.5 not met)
- FR-013.1 The system SHALL support hook types
edit_mode_prepare,edit_mode_start, andedit_mode_stop. - FR-013.2 The system SHALL split a hook’s
scriptinto arguments with quote handling and run it without a shell. - FR-013.3 The system SHALL give a hook the parent environment plus the resolver variables and
KUBECONFIG. - FR-013.4 If a script has an unterminated quote, then the system SHALL fail before running it.
- FR-013.5 The system SHALL use one shell-word splitting implementation for hooks and for edit-mode process commands. (Not met today; see TM-EXEC-002.)
Feature: Hooks
Scenario: No shell expansion
Given a hook script "echo $HOME; touch /tmp/x"
When the hook runs
Then the program "echo" MUST receive the literal arguments
And no file "/tmp/x" MUST be created
Tests: crates/xianyun-update/src/lifecyclehooks.rs.
Security: TM-EXEC-001 through TM-EXEC-003, SR-008.
FR-014: MCP tool surface and gating
Status: Partial (FR-014.4 isn’t met when the first call already carries an answer; see SR-003) Rationale: ADR-0005
- FR-014.1 The system SHALL expose exactly the commands named in the allowlist and no others.
- FR-014.2 If a CLI subcommand is added without being classified as exposed or excluded, then the build SHALL fail.
- FR-014.3 The system SHALL NOT expose
run,tool,kubectl,k9s,stern, oredit-mode-process-supervisor. - FR-014.4 When a gated tool is called without a confirmation answer, the system SHALL return an input-required result and SHALL NOT run the command. A call that carries an answer the server didn’t ask for SHALL be treated as a call without one. (Not met: measured 2026-10-13, a first call with a forged
acceptruns the command.) - FR-014.5 If a client that can’t answer elicitation calls a gated tool, then the system SHALL return a tool error and SHALL NOT run the command.
- FR-014.6 Where the stdio transport is used, the system SHALL read and write the protocol on standard input and output only.
Feature: MCP gating
Scenario: A gated tool needs confirmation
Given a connected MCP client
When the client calls "destroy" with no confirmation
Then the result MUST be input-required
And no cluster MUST be destroyed
Scenario: A decline does nothing
Given the client was asked to confirm "destroy"
When the client answers "decline"
Then the exit code MUST be 1
And stderr MUST contain "aborted at the user's request"
Tests: crates/xianyun-mcp/tests/guardrail.rs, protocol.rs (tool list, an end-to-end version call, and a gated call from a client with no elicitation capability; nothing tests the accept or decline retry), golden_tools.rs.
FR-015: Passthrough tools
Status: Implemented
- FR-015.1 When the user runs
kubectl,k9s,stern, ortool, the system SHALL run the program withKUBECONFIGset to the profile’s kubeconfig and the remaining arguments unchanged. - FR-015.2 When the user runs
logs, the system SHALL print the last--taillines (default 25) from the named processes’ logs.
Feature: Passthrough
Scenario: kubectl sees the profile's cluster
Given a profile with a kubeconfig
When the user runs "xianyun kubectl config current-context"
Then the printed context MUST belong to the profile
Tests: crates/xianyun-cli/tests/kubectl.rs, k9s.rs, stern.rs, logs.rs, crates/xianyun-cluster/tests/clustertool.rs.
Interface requirements
IR-001: Exit codes
- IR-001.1 The system SHALL exit 0 on success and non-zero on any error.
- IR-001.2 The system SHALL return errors from library code as values and SHALL NOT call
std::process::exitoutsidemain.
Tests: crates/xianyun-cli/src/exitcode.rs; all CLI tests assert exit status.
IR-002: Machine-readable output
- IR-002.1 Where a command offers
-o json, the system SHALL print valid JSON on standard output and logs on standard error. - IR-002.2 The system SHOULD offer structured output on
status,doctor, andversion. (Not met; onlyregistry statushas it. The vision document lists this as the next phase.)
Tests: crates/xianyun-cli/tests/registry.rs.
Non-functional requirements
NFR-001: Behavioural parity with the Go tests
The system SHALL keep a test in the Rust workspace for each behaviour the Go test suite verifies, and SHALL document each ignored test with a reason. Measured: 1,142 tests on Linux (1,119 pass, 0 fail, 23 ignored; 22 have a documented reason and one, compile_schema_lazy_loading, is an unimplemented stub). Line coverage was reported as about 90 percent in crates/README.md; that figure wasn’t re-measured in this review.
NFR-002: Process execution goes through CommandRunner without a shell
The system SHALL start external programs through CommandRunner and SHALL NOT invoke a shell to interpret configuration values. Six raw std::process::Command call sites in five files exist outside the seam (listed in ADR-0004); each SHOULD move behind the trait or be justified in code.
NFR-003: Platform support
The system SHALL build and pass its tests on x86_64-linux, aarch64-linux, and aarch64-darwin. Linux was measured in this review. The macOS figures that the macOS notes and crates/README.md used to quote disagreed (1,076 and 1,090) and weren’t re-measured.
NFR-004: Reproducible development environment
The repository SHALL provide a flake.nix development shell with the toolchain and the external tools Xianyun shells out to. nix develop -c cargo build -p xianyun-cli -p xianyun-mcp built cleanly on 2026-10-13, and the devShell also provides cargo-audit and cargo-deny.
NFR-005: The tool collects no telemetry
Status: Implemented
The system SHALL NOT collect or transmit usage data, and the codebase SHALL NOT contain a telemetry client, event types, or a switch for one. A future change that adds telemetry needs a new ADR that supersedes ADR-0018. Checked 2026-10-13 by searching crates/ for telemetry and for the endpoint host the removed code used: no matches outside the porting notes. (See TM-TEL-001, eliminated.)
NFR-006: Config parsing is bounded
The system SHALL reject configuration input larger than a documented limit before parsing and SHOULD move off the deprecated serde_yaml crate. (See TM-CFG-006, TM-SUP-002.)
NFR-007: Dependencies are scanned in CI
Status: Partial (committed and verified locally; not yet run in CI)
The repository SHALL run cargo test --workspace, cargo clippy, and a vulnerability scan (cargo audit or cargo deny) in CI. .gitlab-ci.yml now runs cargo fmt --check, cargo clippy -D warnings, cargo test --workspace, cargo deny check, the example validation, and the documentation build, and deny.toml holds the policy. Each command passes locally; the pipeline hasn’t run on GitLab yet, and the weekly advisory schedule has to be created in the project settings. Measured on 2026-10-13: cargo audit reports 0 vulnerabilities and 4 warnings (see TM-SUP-001).
Security requirements
Status values: Not Started, Partial, Implemented.
SR-001: MCP HTTP binds loopback by default
When mcp stream is started without --host, the system SHALL bind 127.0.0.1, and SHALL refuse a non-loopback address unless authentication is configured. The 0.0.0.0 default comes from the brontes dependency, so the fix is a wrapper in xianyun-mcp or an upstream change.
Threat: TM-MCP-001
Status: Partial. A Host header allow-list exists in rmcp; the default bind was measured as 0.0.0.0 and there’s no authentication.
SR-002: MCP gating covers hook execution
If the resolved configuration contains lifecycle hooks or edit-mode repositories, then up, deploy, and update SHALL require confirmation and SHALL show the hook commands in the prompt.
Threat: TM-MCP-002
Status: Not Started
SR-003: MCP confirmations bind to the exact call
The system SHALL accept a confirmation only if it answers a challenge the server issued for the same command and arguments, and SHALL reject it after one use. A first call that carries an answer SHALL be answered with the challenge, not executed.
Threat: TM-MCP-003
Status: Not Started. Measured: a first call carrying input_responses = {"confirm": {"action": "accept"}} ran xianyun down.
SR-004: Privileged inputs are validated
The system SHALL parse --ip as an IP address before use, SHALL reject hostnames containing whitespace, SHALL validate --kubeconfig paths for MCP calls, and SHOULD ask for confirmation of the hostname list before the first /etc/hosts write.
Threats: TM-HOSTS-001, TM-HOSTS-003, TM-MCP-004
Status: Not Started. Measured: an --ip with an embedded newline produced an extra host entry in rendered content.
SR-005: MCP annotations reflect actual behaviour
The system SHALL set openWorldHint: true for tools that can fetch remote fragments, repositories, or charts.
Threat: TM-MCP-005
Status: Not Started
SR-006: Remote config is pinned or allowlisted
The system SHALL accept a remote fragment only over https (including relative references inside it), SHALL support a content hash per fragment, and SHALL write the fetch cache privately.
Threats: TM-CFG-001, TM-CFG-002
Status: Partial. extends is https-only and size-capped; pinning and relative-reference checks aren’t implemented.
SR-007: Config expansion does not expose the ambient environment
The system SHALL expand only declared variables, predefined variables, and an explicit allowlist from the environment, and SHALL NOT print undeclared environment values in config print.
Threat: TM-CFG-003
Status: Not Started. Measured: an exported variable was expanded and printed.
SR-008: Project files from cloned repositories need opt-in
The system SHALL load xianyun-project.yaml hooks and reloaders from a cloned repository only when that repository’s configuration opts in, SHALL show added hooks when they first appear or change, and SHALL stop config discovery at the repository root or the home directory.
Threats: TM-CFG-004, TM-CFG-007
Status: Not Started
SR-009: Positional arguments cannot be parsed as options
The system SHALL insert -- before user-supplied positional arguments to git, helm, and kustomize where the tool supports it, and SHALL reject values that start with -.
Threat: TM-CFG-005
Status: Not Started
SR-010: Secret-bearing files are created private
The system SHALL create CA key material, kubeconfig backups, the temporary kubeconfigs written for each helm and kubectl call, kustomize staging directories containing secrets, and remote-fragment caches with mode 0600 for files and 0700 for directories.
Threats: TM-PKI-001, TM-PKI-004, TM-KUBE-001, TM-KUBE-002
Status: Partial. ca.key and the kubeconfig are 0600; the backup (measured 0644), the staging directory (755) and its key (644), the temporary kubeconfigs (default mode, not measured), and the PKI directories aren’t.
SR-011: The dev CA is always name-constrained
The system SHALL refuse to generate a CA without Name Constraints, including when PkiCaProvider::load generates one on a profile that has none (it passes an empty permitted list today), and SHALL reject cluster-reported zones that are a single label or a public suffix.
Threats: TM-PKI-002, TM-PKI-003, TM-PKI-006
Status: Partial. Constraints are applied only when the permitted list is non-empty (ca.rs:290).
SR-012: The registry cache isn’t reachable from the network
The managed zot SHALL bind a loopback or bridge address, and SHOULD set an access policy that denies anonymous writes.
Threats: TM-REG-001, TM-REG-003
Status: Not Started. The configuration sets 0.0.0.0 and has no access-control section.
SR-013: Images Xianyun runs are pinned by digest
The system SHALL reference every image it runs in the cluster by digest. Threat: TM-EXEC-004 Status: Not Started
SR-014: Privileged and agent-initiated actions are recorded
The system SHALL append a structured record of each privileged operation and each MCP tool call, including the confirmation outcome, to a file in the data directory. Threat: TM-AUD-001 Status: Not Started
End-to-end scenarios
The Go project’s integration suites drove its binary against real k3d clusters and were removed with the Go tree (ADR-0017). Five of the seven scenarios came back as NixOS VM tests built on nix/tests/lib.nix (ADR-0019). Each was run once to a pass on 2026-10-14, on Linux with KVM, against the final code. The two that need edit mode are blocked by GAP-002.
| Go suite | NixOS VM test | Status | What the VM version checks, and how it differs |
|---|---|---|---|
basic | e2e-deploy | Passing | A namespace, a Kustomize overlay, and a Helm chart install in dependency order on a real cluster; a second deploy upgrades; --repo runs only the named deployer; a broken chart fails and is named. The workloads are ConfigMaps, because the VM can’t pull an application image. |
bundled-ingress | e2e-no-ingress | Passing | A control cluster shows k3s deploys Traefik by default; with k3s.disable: [traefik] the flag reaches the server’s arguments, no Traefik chart is deployed, and the project’s own deployer is what’s installed. The “own ingress” is a ConfigMap chart, not a real controller. |
ca-drift | e2e-ca-drift | Passing | Changing the hostname re-signs the CA with the same public key; a leaf issued earlier still verifies; a leaf for the new name verifies; a leaf for evil.example.com is rejected by the name constraints; ca inspect lists the new constraint. Checked with openssl, outside Xianyun. |
cells | e2e-cells | Passing | Two directories with directory-hash naming become two clusters on distinct host ports, each with its own identity ConfigMap; destroying one leaves the other running. The Go version served an HTTP endpoint per cell; this one can’t. |
registry-cache | e2e-registry-mirror | Passing | A zot container is the bring-your-own cache. The node’s registries.yaml names it, the node gets an HTTP 200 from it, a pull of a tag the cache lacks fails as a pull error, a pull of a seeded tag succeeds, and the cache’s log shows the request. Managed mode isn’t covered. |
mirror | none | Blocked | The mirror reloader, with mirrord. Needs the process supervisor (GAP-002). |
mirror-project | none | Blocked | The same, with the reloader supplied by a xianyun-project.yaml. |
The Go suite that validated every example is replaced by scripts/validate-examples.sh, which CI runs.
Implementation tasks
Each task closes a gap or security requirement above. A task that says “Done” has shipped; for the others, test files named here don’t exist yet.
Task 1: Register production deployer managers (GAP-001)
Done 2026-10-14: register_deployers() in crates/xianyun-cli/src/drivers.rs, the deployer::Manager trait implemented for the three managers, and a Helm undeploy (which the Rust port had never had). Tests: crates/xianyun-cli/tests/deploy_registry.rs, four undeploy tests in crates/xianyun-deploy/src/deployer/helm/manager.rs, and e2e-deploy.
Task 2: Bind the MCP HTTP transport to loopback by default (SR-001)
File: crates/xianyun-mcp/src/main.rs
File: crates/xianyun-mcp/src/lib.rs
Test File: crates/xianyun-mcp/tests/bind_default.rs
Task 3: Validate privileged inputs (SR-004)
File: crates/xianyun-cli/src/commands/updateetchosts.rs
File: crates/xianyun-backends/src/hosts/mod.rs
#![allow(unused)]
fn main() {
#[test]
fn update_rejects_ip_with_embedded_newline() {
let err = parse_ip("1.2.3.4 x\n6.6.6.6 gitlab.com").unwrap_err();
assert!(err.to_string().contains("not a valid IP"));
}
}
Task 4: Create secret-bearing files private (SR-010)
File: crates/xianyun-cluster/src/kubeconfig/file.rs
File: crates/xianyun-backends/src/kustomize/mod.rs
File: crates/xianyun-pki/src/ca.rs
Test File: crates/xianyun-cluster/tests/kubeconfig_backup_mode.rs
Task 5: Stop expanding the ambient environment (SR-007)
File: crates/xianyun-core/src/variables/resolver.rs
Task 6: Add a Rust CI job and a dependency scan (NFR-007)
Done in the working tree on 2026-10-13, awaiting a first pipeline run.
File: .gitlab-ci.yml
File: flake.nix
Task 7: Make confirmations single-use and bound to the call (SR-003)
File: crates/xianyun-mcp/src/config.rs
Test File: crates/xianyun-mcp/tests/forged_confirmation.rs
The test is the measurement behind TM-MCP-003 made permanent: spawn the real binary, send xianyun_down with input_responses = {"confirm": {"action": "accept"}} on the first call, and assert the result is an input-required response and that no command ran.
Requirements traceability matrix
| Requirement | Status | Tests | ADR | Threats |
|---|---|---|---|---|
| FR-001 | Partial | xianyun-core unit, cli/tests/config.rs, e2e-mock | 0003, 0009 | TM-CFG-001 to TM-CFG-007 |
| FR-002 | Implemented | core/src/variables/* | 0009 | TM-CFG-003 |
| FR-003 | Implemented | cli/tests/config.rs, e2e-mock | 0009 | TM-CFG-003 |
| FR-004 | Partial | cli/tests/up.rs (ignored live), cluster/tests/*, e2e-mock, e2e-k3d | 0008 | TM-EXEC-004 |
| FR-005 | Partial | deploy/src/deploy/orchestrator.rs, cli/tests/deploy_registry.rs, e2e-deploy | 0002, 0008, 0019 | none |
| FR-006 | Partial | cli/tests/run*.rs | none | TM-EXEC-001, TM-EXEC-003 |
| FR-007 | Implemented | update/src/updater.rs, cli/tests/update.rs | none | TM-CFG-004, TM-CFG-005 |
| FR-008 | Partial | pki/src/*, cli/tests/ca.rs | 0006 | TM-PKI-001 to TM-PKI-006 |
| FR-009 | Implemented (SHOULD unmet) | backends/src/hosts, cli/tests/updateetchosts.rs | 0004 | TM-HOSTS-001 to TM-HOSTS-003 |
| FR-010 | Partial | cluster/tests/kubeconfig_*.rs, cli/tests/setkubecontext.rs | 0003 | TM-KUBE-001, TM-KUBE-002 |
| FR-011 | Partial | deploy/src/registrycache/*, cli/tests/registry.rs | 0007 | TM-REG-001 to TM-REG-003 |
| FR-012 | Partial | cli/tests/doctor.rs | none | none |
| FR-013 | Partial | update/src/lifecyclehooks.rs | 0004 | TM-EXEC-001 to TM-EXEC-003 |
| FR-014 | Partial | mcp/tests/* | 0005 | TM-MCP-001 to TM-MCP-005, TM-AUD-001 |
| FR-015 | Implemented | cli/tests/kubectl.rs and others | none | none |
| IR-001 | Implemented | all CLI tests | none | none |
| IR-002 | Partial | cli/tests/registry.rs | 0002 | none |
| NFR-001 | Implemented | whole workspace | 0002, 0008 | none |
| NFR-002 | Partial | testutil, backends | 0004 | TM-CFG-005 |
| NFR-003 | Partial | Linux measured; macOS reported | 0008 | none |
| NFR-004 | Implemented | flake.nix | 0008 | none |
| NFR-005 | Implemented | search of crates/ | 0018 | TM-TEL-001 (eliminated) |
| NFR-006 | Not Started | none | 0009 | TM-CFG-006, TM-SUP-002 |
| NFR-007 | Partial | .gitlab-ci.yml, deny.toml | 0008, 0017 | TM-SUP-001 |
| SR-001 to SR-014 | see each | see each | see each | see each |
Gaps in the matrix: FR-006, FR-012, FR-015, and IR-001 have no ADR; NFR-006 has no test.
Changelog
| Date | Change |
|---|---|
| 2026-10-13 | Initial retroactive specification, created by the all-aboard review |
User stories
Each story is a behaviour written as scenarios a test can check. They’re all in Revision 1: written and reviewed once, not yet backed by passing tests. Several scenarios fail today, and each story says which.
| Story | Actor |
|---|---|
| STORY-001 Developer brings up a cluster and deploys a chart | Developer |
| STORY-002 Agent validates over MCP with gated destructive operations | AI agent |
| STORY-003 Developer trusts the dev CA locally | Developer |
| STORY-004 Existing Caproni project works unmodified | Developer |
The documentation review lists the stories still to write.
Story: Developer brings up a cluster and deploys a chart
Story ID: STORY-001 Status: Revision 1 Created: 2026-10-13
Statement
As a developer, I want xianyun up to start a local cluster and install the deployers my config lists, so that I can validate a change against a real Kubernetes API without assembling the environment by hand.
Trigger
The developer runs xianyun up in a directory containing xianyun.yaml that lists at least one deployer.
Acceptance Criteria
Scenario: A namespace deployer runs after the cluster starts
Given a k3d-driver config with a "namespace" deployer that lists namespace "demo"
And docker and k3d are available
When the developer runs "xianyun up"
Then the exit code MUST be 0
And "kubectl get namespace demo" MUST succeed
And "kubectl get nodes" MUST report a Ready node
Scenario: Deployers run in dependency order
Given deployer "ingress" with needs ["namespaces"] and a "namespace" deployer named "namespaces"
When the developer runs "xianyun deploy"
Then "namespaces" MUST finish before "ingress" starts
And a failure in "namespaces" MUST stop the run before "ingress" starts
Scenario: A missing tool is reported, not a panic
Given a k3d-driver config and no "k3d" on PATH
When the developer runs "xianyun up"
Then the exit code MUST be non-zero
And the message MUST name "k3d"
And the output MUST NOT contain "panicked at"
Scenario: Deploying one repository’s deployers
Given deployers "web" and "api" that use different repositories
When the developer runs "xianyun deploy --repo web"
Then only the "web" deployer MUST run
EARS Constraints
- When
upis run, the system shall start the cluster before running any deployer. - If a deployer type has no registered manager, then the system shall fail naming the type and shall not report success.
- While a deployer is running, the system shall not start a deployer that lists it in
needs. - If the cluster API doesn’t answer within the wait period, then the system shall exit non-zero without running deployers.
Autonomy Limits
Not applicable; the actor is a human. (An agent calling the same flow through MCP is covered in STORY-002.)
Security Mapping
This story crosses three trust boundaries (config sources to the loader, Xianyun to the cluster, Xianyun to other tools), so all six STRIDE categories are listed.
| Concern | Addressed By |
|---|---|
| Spoofing (a cluster that isn’t the intended one) | Caproni’s documentation says up verifies connectivity to an external cluster’s kubeconfig context before deploying; that wasn’t checked in the Rust code in this review, and showing the context name in the output isn’t specified |
| Tampering (option-shaped positional values, unpinned discovery image) | SR-009, SR-013 (not yet met) |
| Repudiation (which hooks and deployers ran) | No durable record today; SR-014 (not yet met) |
| Information disclosure (temporary kubeconfig left readable) | SR-010, TM-KUBE-002 (not yet met) |
| Denial of service (oversized or alias-bomb config; cluster that never answers) | Fourth EARS constraint covers the wait; NFR-006 covers parsing (not yet met) |
| Elevation of privilege (config-defined hooks) | Prepare hooks run during up (TM-EXEC-001); SR-002 covers the MCP case |
Derived Tests
| Scenario | Test type | Framework |
|---|---|---|
| Namespace deployer runs after start | Integration | nix/tests/e2e-k3d.nix (Tier 2, needs a deployer-bearing config) and crates/xianyun-cli/tests/deploy_registry.rs (planned, unit-level) |
| Dependency order | Unit | #[test] in crates/xianyun-deploy/src/deploy/orchestrator.rs (exists) |
| Missing tool reported | Unit | #[test] in crates/xianyun-cluster/tests/k3d_driver.rs (error variant exists; message assertion planned) |
| Single repository | Unit | #[test] in crates/xianyun-cli/tests/deploy.rs (ignored live test exists) |
Standards Mapping
- SLSA Level: N/A
- SSDF Practices: N/A
- SCVS Controls: N/A
Dependencies
- Depends on: none
- Blocks: STORY-002 (the agent flow reuses
upanddeploy) - Related ADR: ADR-0002, ADR-0008
- Related spec requirement: FR-004, FR-005
Notes
- The first, second, and fourth scenarios pass in the
e2e-deployNixOS VM test, which uses a namespace, a Kustomize overlay, and a Helm chart rather than a Helm chart from a registry. Until 2026-10-14 the first and second failed: the production setup registered no deployer managers (spec GAP-001, closed). - The third scenario has a code path (
DriverError::DriverNotFound("k3d")ink3d.rs:86) but no CLI-level test checks the message or the absence of a panic. upwith the mock driver can’t stand in for a cluster: it builds a real kubectl client against the mock’s fake kubeconfig, and the run stopped at “getting kubectl version” in this review.
Story: Agent validates over MCP with gated destructive operations
Story ID: STORY-002 Status: Revision 1 Created: 2026-10-13
Statement
As an AI agent running a verification loop, I want to call a reviewed set of Xianyun commands over MCP and get structured results back, so that I can check a change against a local cluster without a person copying terminal output, while a person still approves anything that destroys state.
Trigger
An MCP client connects to xianyun-mcp (stdio or Streamable HTTP) and calls a tool.
Acceptance Criteria
Scenario: A read-only tool runs without confirmation
Given a connected MCP client and a valid xianyun.yaml in the working directory
When the client calls the "status" tool
Then the result MUST include the command's exit code and output
And no confirmation MUST be requested
Scenario: A destructive tool needs confirmation first
Given a connected MCP client that supports elicitation
When the client calls "destroy"
Then the first result MUST be input-required and carry a consequence message
And the cluster MUST still exist
When the user answers "accept" and the client retries
Then the cluster MUST be destroyed
Scenario: A declined confirmation changes nothing
Given the client has been asked to confirm "update-etc-hosts"
When the user answers "decline"
Then the exit code MUST be 1
And stderr MUST contain "aborted at the user's request"
And /etc/hosts MUST be unchanged
Scenario: The HTTP transport isn’t reachable from other hosts by default
Given "xianyun-mcp mcp stream --port 18099" is started with no --host
When another host connects to port 18099 on the machine's LAN address
Then the connection MUST be refused
EARS Constraints
- The system shall expose only the commands named in the allowlist.
- If a CLI subcommand isn’t classified as exposed or excluded, then the build shall fail.
- If a client can’t answer elicitation, then the system shall return a tool error and shall not run a gated command.
- While the stdio transport is in use, the system shall write only protocol messages to standard output.
- If a call carries a confirmation answer the server didn’t ask for, then the system shall respond with the challenge and shall not run the command. (Not met: measured 2026-10-13, a forged first-call
acceptranxianyun down; see SR-003.) - When any gated or privileged call completes, the system shall append an audit record naming the command, arguments, and confirmation outcome.
Autonomy Limits
- Maximum actions per cycle: not specified and not enforced. Open decision: pick a number (for example, state-changing calls per session before a fresh human confirmation) and add it as a security requirement. Until then this field is a known gap, not a limit.
- Escalation threshold: any gated tool always escalates to the human; any call whose resolved config contains hooks should (SR-002, not yet met).
- Prohibited actions:
run,tool,kubectl,k9s,stern, andedit-mode-process-supervisoraren’t exposed. - Human oversight: required for the five gated tools. Not required for
up,deploy, andupdate, which is the question SR-002 raises.
Security Mapping
| Concern | Addressed By |
|---|---|
| Spoofing (unauthenticated listener) | Fourth scenario; SR-001 |
| Spoofing (forged confirmation) | EARS constraint on unsolicited answers; SR-003 (not yet met) |
| Elevation of privilege (hooks through ungated tools) | SR-002 (not yet met) |
| Tampering (crafted flag values) | SR-004 (not yet met) |
Denial of service (a client calling up or deploy in a loop; no rate or concurrency limit) | Not specified; tied to the open decision under Autonomy Limits |
| Repudiation (no record of calls) | EARS constraint above; SR-014 (not yet met) |
Information disclosure (config print shows environment values) | SR-007 (not yet met) |
Standards Mapping
- SLSA Level: N/A
- SSDF Practices: PW.1 (secure design) and PW.5 (secure coding), informally
- SCVS Controls: N/A
Dependencies
- Depends on: none. The scenarios use read-only and gated tools; the
upanddeployflows in STORY-001 are reached through the same CLI but aren’t needed to verify the gating. - Blocks: none
- Related ADR: ADR-0005
- Related spec requirement: FR-014, SR-001, SR-002, SR-003
Notes
- Partly covered today.
crates/xianyun-mcp/tests/protocol.rshas three tests: the tool list, an end-to-endversioncall, and a gated call without confirmation not running. Nothing exercises theacceptretry or thedeclinepath of scenarios two and three, and scenario one is checked withversionrather thanstatus.guardrail.rscovers the allowlist constraint. - The fourth scenario fails today. Measured on 2026-10-13: with no
--host, the server listened on0.0.0.0:18099. - Open question for negotiation: should
up,deploy, andupdatemove to the gated tier, or be gated only when hooks are present (the cheaper option that SR-002 proposes)?
Story: Developer trusts the dev CA locally
Story ID: STORY-003 Status: Revision 1 Created: 2026-10-13
Statement
As a developer, I want Xianyun to generate a name-constrained dev CA and install it in my trust stores, so that browsers and tools accept the local cluster’s certificates without warnings and the CA can’t vouch for domains I don’t use.
Trigger
The developer runs xianyun ca trust, or runs xianyun up with the builtin:dev-ca deployer.
Acceptance Criteria
Scenario: First trust generates and installs the CA
Given no CA exists for profile "dev" and the config hostname is "gitlab.dev"
When the developer runs "xianyun ca trust" and approves the sudo prompt
Then a CA certificate MUST exist under the profile's pki "current" directory
And its permitted DNS subtrees MUST be ".local", ".test", ".svc", and "gitlab.dev"
And the system trust store MUST contain the certificate
Scenario: Untrust removes current and legacy entries
Given the CA is trusted under both the Xianyun name and the legacy Caproni name
When the developer runs "xianyun ca untrust"
Then neither entry MUST remain in the system trust store
And neither entry MUST remain in any NSS database where certutil is available
Scenario: The CA key isn’t exposed during a build
Given a profile with a generated CA and the "builtin:dev-ca" deployer
When "kustomize build" runs against the staged overlay
Then every staged file containing the CA key MUST have mode 0600
And the staging directory MUST have mode 0700
Scenario: Missing NSS tooling doesn’t fail the trust
Given certutil isn't on PATH
When the developer runs "xianyun ca trust"
Then the exit code MUST be 0
And the output MUST note that NSS databases weren't updated
EARS Constraints
- The system shall create
ca.keywith mode 0600 and shall never overwrite an existing key file. - If the CA’s constraints change, then the system shall re-sign with the same key.
- If the CA has expired, then the system shall generate a new one.
- When loading a CA, the system shall verify the certificate matches the private key.
- If
sudois unavailable and the user isn’t root, then the system shall report that the trust store wasn’t changed. (Not met:apply_sudologs a warning and runs the command without privileges, so the user sees the tool’s own permission error.)
Autonomy Limits
Not applicable for a human actor. Through MCP, ca trust and ca untrust are gated tools; ca inspect isn’t, and can generate a CA on first run.
Security Mapping
| Concern | Addressed By |
|---|---|
| Information disclosure (key in staging) | Third scenario; SR-010 (not yet met; measured 755 directory, 644 key) |
| Elevation of privilege (CA outside intended domains) | First scenario subtrees; SR-011 (partial) |
| Tampering (swapped key or certificate) | EARS constraint on loading; FR-008.6 (not yet met) |
| Spoofing (hostile cluster-reported zone) | SR-011 (not yet met, unproven exploit) |
| Repudiation (who installed or removed a trust anchor, and when) | No durable record today; SR-014 (not yet met) |
| Denial of service | Not applicable beyond the single-user local case; the 10-year lifetime and the expiry regeneration are the relevant limits |
Standards Mapping
- SLSA Level: N/A
- SSDF Practices: N/A
- SCVS Controls: N/A
Dependencies
- Depends on: none
- Blocks: none
- Related ADR: ADR-0006, ADR-0003
- Related spec requirement: FR-008, SR-010, SR-011
Notes
- The Keychain path on macOS was read, not run, in this review. A macOS variant of the first two scenarios needs the macOS build host.
- The third scenario fails today (see the threat model, TM-PKI-001).
- Whether every client enforces Name Constraints (TM-PKI-006) is unproven; the first scenario checks the certificate’s content, not client behaviour.
Story: Existing Caproni project works unmodified
Story ID: STORY-004 Status: Revision 1 Created: 2026-10-13
Statement
As a developer with an existing Caproni project, I want xianyun to read my current config, environment variables, and on-disk state unchanged, so that I can switch tools without rewriting my project or losing my trusted CA and registry cache.
Trigger
The developer runs any xianyun command in a directory that has caproni.yaml (and possibly caproni.local.yaml, caproni-project.yaml, and ~/.local/share/caproni).
Acceptance Criteria
Scenario: Legacy config filenames load
Given a directory with only "caproni.yaml" and "caproni.local.yaml"
When the developer runs "xianyun config validate"
Then the exit code MUST be 0
And values from "caproni.local.yaml" MUST be merged over "caproni.yaml"
Scenario: An invalid legacy config is reported against the schema
Given a directory with only "caproni.yaml" that sets "cluster.driver: nonsense"
When the developer runs "xianyun config validate"
Then the exit code MUST be non-zero
And the message MUST name "/cluster/driver"
And no cluster MUST be started
Scenario: Legacy variables and environment aliases still work
Given a config value "${CAPRONI_CLUSTER_NAME}" and CAPRONI_NO_CACHE set
When the developer runs "xianyun config print"
Then the value MUST equal the "XIANYUN_CLUSTER_NAME" value
And the cache MUST be disabled as if XIANYUN_NO_CACHE were set
Scenario: Legacy state is reused, not duplicated
Given a CA under "~/.local/share/caproni" and none under "~/.local/share/xianyun"
When the developer runs "xianyun ca inspect"
Then the output MUST show the existing CA
And no new CA MUST be generated
EARS Constraints
- When both a primary and a legacy file exist in one directory, the system shall use the primary. (Covered by FR-001.2’s scenario rather than here.)
- The system shall name the in-cluster ClusterIssuer and Secret
xianyun-caand shall not createcaproni-caobjects. - If
--no-cacheorXIANYUN_NO_CACHEis set, then the system shall disable remote-fragment caching. (Not met: the flag is read but unused, GAP-005.) - When a legacy trust-store entry exists, the system shall remove it on
ca untrust.
Autonomy Limits
Not applicable; the actor is a human.
Security Mapping
| Concern | Addressed By |
|---|---|
| Tampering (legacy file shadowing a primary one) | Second scenario fixes precedence |
| Information disclosure (legacy backup file) | .caproni.bak is still written 0644; SR-010 (not yet met) |
Standards Mapping
- SLSA Level: N/A
- SSDF Practices: N/A
- SCVS Controls: N/A
Dependencies
- Depends on: none
- Blocks: none
- Related ADR: ADR-0003, ADR-0009
- Related spec requirement: FR-001, FR-002
Notes
- The invalid-config scenario was measured on 2026-10-13:
xianyun config validateagainst acaproni.yamlwithdriver: nonsenseexited 1 with/cluster/driver: "nonsense" is not one of [...]. That run also shows a legacycaproni.yamlis found and read. The valid-config scenario needs acluster.networkblock. - The first and third scenarios are covered by unit tests in
xianyun-core; none has been exercised against an on-disk legacy state in a VM test. - Documentation gap: user docs still say
caproni-caand~/.local/share/caproni, andexamples/own-infrastructure.yamlpasses--default-issuer-name=caproni-ca. A user following those againstxianyunreferences an issuer that doesn’t exist. This is the most likely way the “unmodified” promise breaks in practice. - The data-directory scenario is split in the code:
k3d.rsandcolima.rshard-code.local/share/caproni, so some state may be created in the old location even for new profiles.
Xianyun threat model
Status: Draft v1
Date: 2026-10-13
Scope: the Rust workspace under crates/ (the xianyun and xianyun-mcp binaries)
Method: STRIDE per element, DREAD scoring, data flow diagram with trust boundaries
Not covered: the Go original, which is no longer in the tree, the Kubernetes workloads users deploy, and the upstream tools Xianyun shells out to (helm, kubectl, k3d, zot, mirrord, skaffold).
Several findings below describe weaknesses that haven’t been fixed yet. They’re published here so that the specification and the decision records can link to them; each carries its evidence tag and the measurement that would confirm it.
How to read the evidence tags
Every threat carries one of these tags, taken from the project rule that a cause isn’t real until a measurement isolates it.
- MEASURED means a test or command was run during this review and the result is quoted.
- CODE means the behaviour is visible in the source at the cited path, but it wasn’t executed. The exploit path is plausible and unproven.
- UNPROVEN names the specific measurement that would confirm or refute the threat.
Measurements were taken on 2026-10-13 against main at commit d5f74650, on Linux, with umask 022, using throwaway tests and temporary HOME directories that were deleted afterwards. The reproducers weren’t committed, so a reader can’t re-run them today; Tasks 3, 4, and 7 in the spec turn them into committed tests.
System description
Xianyun is a command-line orchestrator for local Kubernetes development. A developer (or an AI agent, through the MCP server) runs commands that read a YAML config, start a local cluster through a driver (k3d, colima, vcluster, external, mock), deploy Helm and Kustomize workloads, run hot-reload processes, and manage host state: /etc/hosts, the system trust store, and ~/.kube/config.
It has no long-running server of its own except two optional listeners: the MCP Streamable HTTP transport and a zot registry cache. Everything else is short-lived process execution on the developer’s workstation, running as the developer’s user, with sudo for a small set of privileged writes.
Assets
| Asset | Why it matters |
|---|---|
Dev CA private key (ca.key) | Anything signed by it is trusted by the developer’s browsers and tools |
| Kubeconfig credentials | Cluster-admin access to the local cluster |
| Developer workstation integrity | Xianyun runs commands from config with the developer’s privileges |
/etc/hosts and system trust store | Redirect or intercept traffic for any hostname |
| Process environment | May hold tokens and cloud credentials |
| Registry cache contents | Images that clusters pull without further verification |
Data flow diagram
[E1 AI agent / MCP client] [E2 Developer]
| TB1 |
v v
(P1 xianyun-mcp) --re-exec--------> (P2 xianyun CLI)
| | | |
TB2 +-------------------------------------------+ | | +---------------+ TB3
v | | v
[E3 Remote config host]--https-->(P3 config loader) | | (P6 privileged writer: sudo)
[E4 Cloned edit-mode repos]------> | | | | |
| v | | v v
| [D1 xianyun.yaml + | | [D5 /etc/hosts] [D6 system and
| local overlay] | | NSS trust store]
| | |
| TB6 | | TB4
v v v v v
(P4 hook and reloader runner) --argv--> [E5 helm, kubectl, git, k3d, kustomize, mirrord, mise]
| |
TB5 v v
[D2 CA key store] [D3 kustomize staging in $TMPDIR] [D4 kubeconfig and .bak]
|
v
[E6 local cluster: API server, Secrets]
^
TB7 (P5 zot registry cache, 0.0.0.0:5555) <--- [E7 other hosts on the network]
Trust boundaries
| ID | Boundary | What crosses it |
|---|---|---|
| TB1 | MCP client to xianyun-mcp | Tool calls with free-form args and flags; elicitation answers |
| TB2 | Config sources to the loader | Local YAML, remote https fragments, xianyun-project.yaml from cloned repos, environment variables, file-typed variables |
| TB3 | Unprivileged process to sudo | tee /etc/hosts, install, update-ca-certificates, security add-trusted-cert |
| TB4 | Xianyun to the cluster | Kubeconfig, applied manifests, cluster-reported DNS zone and node data |
| TB5 | Xianyun to other local users | $TMPDIR, ~/.kube, ~/.local/share/xianyun |
| TB6 | Xianyun to executables on PATH | argv, inherited environment |
| TB7 | Registry cache listener to the network | OCI pulls and (possibly) pushes |
Assumptions
Each assumption is a risk if it turns out to be wrong.
- The developer controls the workstation and trusts the config file in their own repository. Config content is code, because lifecycle hooks run it.
- Remote config fragments and cloned repositories are less trusted than the local config file. Nothing in the codebase currently enforces that difference.
- The MCP client is operated by an LLM that can be steered by untrusted text (prompt injection). Gating that depends on the model’s good behaviour doesn’t count as a control.
- The machine can be multi-user. A shared Linux server with a world-readable
/tmpis a supported way to run the tool. - Xianyun collects no telemetry (ADR-0018), so no threat here concerns usage data leaving the machine.
Threat register
DREAD totals use Damage, Reproducibility, Exploitability, Affected users, Discoverability, each 0 to 10. Bands: 40+ Critical, 25 to 39 High, 11 to 24 Medium, 10 and under Low. No threat here reached Critical. Scores are the reviewer’s judgement; the evidence tag says how much of each was observed.
Mitigation status values follow the repository’s artifact status rules: Not Started, PARTIAL, DESIGNED, IMPLEMENTED.
MCP server (P1, TB1)
TM-MCP-001: Unauthenticated Streamable HTTP listener binds all interfaces by default
Element: P1 xianyun-mcp
Category: Spoofing, Elevation of Privilege
Evidence: MEASURED. xianyun-mcp mcp stream --port 18099 with no --host produced LISTEN 0.0.0.0:18099. The help text says “Host to bind (empty → 0.0.0.0)”. The README example passes --host 127.0.0.1 but doesn’t mention the default.
Description: The listener accepts connections on every interface and has no authentication. The only gate is rmcp’s Host header allow-list (localhost, 127.0.0.1, ::1, extended by --allow-host), which rejects a LAN client that sends its own hostname with a 403 but not a non-browser client that sends Host: localhost. The 0.0.0.0 default is in the brontes dependency (brontes-0.7.3/src/subcommands/stream.rs, resolve_bind_host); xianyun-mcp only mounts that subcommand.
Attack vector: A client on the same LAN, container network, or VPN that sets Host: localhost calls the ungated up, deploy, or update tools. Those run config-defined hooks (see TM-MCP-002). The gated tools only check a client-supplied answer, which the attacker supplies (see TM-MCP-003, measured).
Affected assets: workstation integrity, kubeconfig credentials.
DREAD: D:8 R:8 E:6 A:3 D:6 = 31 (High)
Mitigation: Wrap or override the stream subcommand in xianyun-mcp so --host defaults to 127.0.0.1 (or fix it upstream in brontes); refuse a non-loopback bind unless authentication is configured; document the default.
Status: ⚠️ PARTIAL (DNS-rebind allow-list exists in rmcp; the bind default and authentication aren’t addressed)
ADR: ADR-0005
Spec Requirement: SR-001
TM-MCP-002: Ungated state-changing tools can execute hook commands from config
Element: P1 to P2 to P4
Category: Elevation of Privilege
Evidence: CODE. up, deploy, and update are in the ungated tier (crates/xianyun-mcp/src/config.rs). All three can run prepare hooks: up at crates/xianyun-cli/src/commands/up.rs:342, deploy at crates/xianyun-cli/src/commands/deploy.rs:120-161, and update at crates/xianyun-update/src/updater.rs:162-177. Hooks go through run_in_repository, which has no shell but does run an arbitrary argv.
Description: The “reversible” classification treats these tools as local-only, but a config (or a remote fragment, or an edit-mode repo’s project file) can define hooks that run any program as the developer.
Attack vector: A prompt-injected agent calls update to pull a repository whose xianyun-project.yaml adds a hook, then calls up.
Affected assets: workstation integrity, process environment.
DREAD: D:8 R:6 E:5 A:3 D:5 = 27 (High)
Mitigation: Gate up, deploy, and update when the resolved config contains hooks or edit-mode repositories, or move them to the gated tier; show the resolved hook commands in the consequence message.
Status: Not Started
ADR: ADR-0005
Spec Requirement: SR-002
TM-MCP-003: The confirmation gate accepts a forged answer on the first call
Element: P1
Category: Spoofing, Elevation of Privilege
Evidence: MEASURED. The middleware (crates/xianyun-mcp/src/config.rs:156-190) returns InputRequired only when ctx.input_responses is None; otherwise it runs the command if input_responses["confirm"] deserialises to an Accept action. Nothing records that a challenge was issued. A throwaway test against the real xianyun-mcp binary (working directory and HOME set to an empty temp directory) made two xianyun_down calls. With no input_responses the call was refused (“middleware asked the client for input, which requires MCP protocol 2026-07-28 or newer”). With input_responses = {"confirm": {"action": "accept"}} on the first call, the command ran: the result was the CLI’s own Error: finding config file: no caproni.yaml found.... brontes passes the client’s input_responses straight to the middleware (brontes-0.7.3/src/server/mod.rs:304-306).
Description: The gate is stateless and trusts whatever the client sends. A client that sets the field, or an MCP client implementation that auto-approves, skips the human entirely. The user also sees only the consequence message, not the free-form arguments.
Attack vector: A compromised or hostile MCP client (including one reached through TM-MCP-001) calls destroy, update-etc-hosts, or ca trust with a pre-filled answer. A prompt-injected LLM can’t set protocol fields through ordinary tool arguments, so the realistic route is the client, not the model.
Affected assets: cluster data, /etc/hosts, trust store.
DREAD: D:7 R:9 E:5 A:2 D:6 = 29 (High)
Mitigation: Bind the confirmation to a server-issued, single-use nonce for the exact command and arguments (carry it in request_state); include the arguments in the prompt.
Status: Not Started
ADR: ADR-0005
Spec Requirement: SR-003
TM-MCP-004: Free-form arguments reach flags with host-level effects
Element: P1
Category: Tampering, Elevation of Privilege
Evidence: CODE. Every tool exposes free-form args and flags (crates/xianyun-mcp/tests/snapshots/tools.json). Reachable flags include update-etc-hosts --ip, set-kube-context --kubeconfig, and update --stash. (config migrate --force --skip-backup is also exposed but returns “already latest” before reading either flag, so it’s latent until a second config version exists.)
Description: The allowlist works at the command level. Inside an allowed command, an agent can still pass a flag whose value changes what a privileged operation writes (see TM-HOSTS-001) or where a file lands.
Attack vector: A prompt-injected agent calls update-etc-hosts with a crafted --ip, or set-kube-context --kubeconfig with a path it shouldn’t touch.
Affected assets: /etc/hosts, ~/.kube/config.
DREAD: D:7 R:7 E:5 A:2 D:5 = 26 (High)
Mitigation: Validate flag values server-side (IP parse, path under an allowed root); consider removing --stash and, ahead of a v3 schema, --force and --skip-backup from the MCP surface.
Status: Not Started
ADR: ADR-0005
Spec Requirement: SR-004
TM-MCP-005: Tool annotations understate network access
Element: P1
Category: Information Disclosure
Evidence: MEASURED in the committed snapshot: every tool has openWorldHint: false. CODE: config validate, up, deploy, and update fetch remote fragments and repositories.
Description: Clients use annotations to decide how much to trust a call. A “read-only, closed-world” config validate can still trigger network fetches from URLs in the config.
Attack vector: An agent or auto-approve policy treats config validate as safe and runs it on an attacker-supplied config.
Affected assets: workstation network position, remote fragment cache.
DREAD: D:3 R:9 E:3 A:2 D:3 = 20 (Medium)
Mitigation: Set openWorldHint: true on tools that can fetch; keep readOnlyHint only where nothing is written.
Status: Not Started
ADR: ADR-0005
Spec Requirement: SR-005
Configuration loading (P3, TB2)
TM-CFG-001: Remote extends fragments have no pinning, signature, or allowlist
Element: P3, E3
Category: Tampering, Elevation of Privilege
Evidence: CODE (crates/xianyun-core/src/config/merge.rs:374-384). Only https and a xianyun*.yaml or caproni*.yaml basename are enforced; the fetch uses default TLS roots and a 10 MiB cap.
Description: A fragment can add repositories, lifecycle_hooks, reloaders, and deployers, including kustomize deployers whose remote targets (http, https, ssh, or file, merge.rs:~971-981) are passed straight to kustomize build with no pin, and a registry_mirror.endpoint that redirects image pulls. Whoever controls the URL’s content, or compromises the host, gets command execution on the next up.
Attack vector: A shared team config points at a fragment on a compromised or re-registered domain.
Affected assets: workstation integrity.
DREAD: D:9 R:6 E:4 A:5 D:4 = 28 (High)
Mitigation: Support a content hash or pinned commit for each remote fragment; add an allowlist of hosts; print the resolved fragment URLs and hook commands before the first run.
Status: Not Started
ADR: ADR-0009
Spec Requirement: SR-006
TM-CFG-002: Relative file references inside a remote fragment may accept http://
Element: P3
Category: Tampering, Information Disclosure
Evidence: CODE. resolve_relative_url returns a reference unchanged when it contains :// (merge.rs:1062); the https-only check at merge.rs:384 covers extends only. Downloads land in the predictable path $TMPDIR/xianyun-remote-fragments/<sha256(url)>, written with default mode and no O_EXCL (merge.rs:1080-1125).
UNPROVEN. Measurement: a fragment whose helm.values_file is http://127.0.0.1:<port>/x, with a listener to see whether it’s fetched; and a pre-placed symlink at the predictable path on a shared /tmp.
DREAD: D:5 R:5 E:4 A:3 D:3 = 20 (Medium)
Mitigation: Reject non-https URLs in every fetch; create the cache directory 0700 and write with O_EXCL or a tempfile plus rename.
Status: Not Started
ADR: ADR-0009
Spec Requirement: SR-006
TM-CFG-003: Any process environment variable can be expanded and printed
Element: P3
Category: Information Disclosure
Evidence: MEASURED. With cluster_domain: ${DEMO_SECRET_TOKEN} in the config and DEMO_SECRET_TOKEN=hunter2.example in the environment, xianyun config print printed cluster_domain: hunter2.example. crates/xianyun-core/src/variables/resolver.rs:47-53 falls back to the process environment for any name.
Description: Config content (including a remote fragment) can reference any variable the developer has exported, and config print, a read-only MCP tool, shows the result to the agent.
Attack vector: A fragment references ${AWS_SECRET_ACCESS_KEY} in a field that config print echoes; the agent’s context now holds the secret.
Affected assets: process environment.
DREAD: D:6 R:8 E:4 A:2 D:5 = 25 (High)
Mitigation: Restrict environment fallback to an explicit allowlist or a XIANYUN_/CAPRONI_ prefix; redact values from variables not declared in the config’s own variables block when printing.
Status: Not Started
ADR: ADR-0009
Spec Requirement: SR-007
TM-CFG-004: A cloned edit-mode repository can contribute hooks through its project file
Element: P3, E4
Category: Elevation of Privilege
Evidence: CODE (merge.rs:849-940). The xianyun-project.yaml (or caproni-project.yaml) found in an edit_mode repository’s working tree adds reloaders and lifecycle_hooks to the merged config.
Description: Pointing repositories at a third-party repo is enough for that repo to run commands during up and run.
Attack vector: A malicious commit to a tracked branch of an edit-mode repository; xianyun update fast-forwards to it.
Affected assets: workstation integrity.
DREAD: D:8 R:6 E:4 A:3 D:4 = 25 (High)
Mitigation: Require explicit per-repository opt-in (allow_project_config: true) and show added hooks on first load or when they change.
Status: Not Started
ADR: ADR-0009
Spec Requirement: SR-008
TM-CFG-005: Positional arguments to git, helm, and kustomize accept option-shaped values
Element: P3 to E5
Category: Tampering, Elevation of Privilege
Evidence: CODE. git clone <url> <dir> has no -- separator (crates/xianyun-backends/src/git/mod.rs:196); helm pull <chart_ref> and kustomize build <target> are also positional.
UNPROVEN. Measurement: load a config with repository: "--upload-pack=touch /tmp/x" and see whether schema validation ("format": "uri") rejects it, then whether git clone runs it. No shell is involved, so only the target tool’s own option parsing matters.
DREAD: D:6 R:4 E:4 A:2 D:3 = 19 (Medium)
Mitigation: Insert -- before positional values where the tool supports it; reject values that begin with -.
Status: Not Started
ADR: ADR-0004
Spec Requirement: SR-009
TM-CFG-006: YAML alias expansion could exhaust memory
Element: P3
Category: Denial of Service
Evidence: UNPROVEN. serde_yaml 0.9.34 is used with no documented expansion limit. Measurement: load a “billion laughs” file and watch peak RSS.
DREAD: D:3 R:5 E:3 A:2 D:3 = 16 (Medium)
Mitigation: Cap input size before parsing (the remote fetch already caps at 10 MiB; local files have no cap); test with an alias bomb.
Status: Not Started
ADR: ADR-0009
Spec Requirement: NFR-006
TM-CFG-007: Config discovery walks to the filesystem root outside the home directory
Element: P3
Category: Elevation of Privilege
Evidence: CODE. find_config_file (crates/xianyun-core/src/config/global.rs:107-149) stops only when search_dir == home. From a working directory outside $HOME it checks every ancestor up to /. (Inside the measured temp-home run the error read “up to home directory”, confirming the stop rule applies there.)
UNPROVEN. Measurement: create /tmp/xianyun.yaml with a lifecycle hook, run xianyun config validate from /tmp/x/y with a different HOME, and see whether it loads.
Description: On a shared server, a file planted in /tmp or another common ancestor is picked up by anyone who runs the tool from beneath it, and the config can carry hooks.
DREAD: D:8 R:4 E:4 A:2 D:3 = 21 (Medium)
Mitigation: Stop at the repository root (.git) or the home directory, and refuse configs not owned by the current user.
Status: Not Started
ADR: ADR-0009
Spec Requirement: SR-008
Dev CA and key material (D2, D3, TB5)
TM-PKI-001: The CA private key is copied into a world-readable staging directory
Element: D3 kustomize staging
Category: Information Disclosure
Evidence: MEASURED. A kustomize build of a directory containing tls.key staged to xianyun-kustomize-<pid>-<n>-<nanos> with mode 755, containing tls.key at mode 644, under umask 022. StagingDir::create and stage_directory (crates/xianyun-backends/src/kustomize/mod.rs:240-291) set no modes. The key comes from write_ca_material (crates/xianyun-deploy/src/deployer/kustomize/camaterial.rs).
Description: For the length of each kustomize build, any local user who can list $TMPDIR can read the CA key. The name is guessable from the PID and a timestamp, and the directory is listable. No longer latent: this path runs through the kustomize deployer with builtin:dev-ca. GAP-001 was closed on 2026-10-14, so the deployer runs. The measurement was taken at the backend, and the e2e-deploy scenario doesn’t use builtin:dev-ca, so the staged key’s mode in a real up is still unmeasured.
Attack vector: On a shared host, a second user polls /tmp for xianyun-kustomize-* and copies tls.key.
Affected assets: dev CA private key.
DREAD: D:7 R:8 E:6 A:4 D:5 = 30 (High)
Mitigation: Create staging with tempfile::Builder (mode 0700) and write secret-bearing files 0600.
Status: Not Started
ADR: ADR-0006
Spec Requirement: SR-010
TM-PKI-002: The CA key is deployed into the cluster as a Secret
Element: D2 to E6
Category: Information Disclosure
Evidence: CODE. PkiCaProvider reads both PEMs and the kustomize overlay turns them into a Secret consumed by cert-manager.
Description: Anyone with read access to Secrets in the local cluster holds the CA key. The Name Constraints on the CA are the remaining limit (see TM-PKI-003 and TM-PKI-006), and they aren’t guaranteed: PkiCaProvider::load calls load_or_generate(&pki_dir, &[]) (camaterial.rs:76), so on a profile with no CA yet it generates one with no constraints (ca.rs:290, if !permitted_dns.is_empty()). The code comment at camaterial.rs:72-75 assumes a pure load. The Secret path is reachable now that deployers run (GAP-001, closed), though no scenario deploys builtin:dev-ca.
DREAD: D:6 R:5 E:3 A:3 D:4 = 21 (Medium)
Mitigation: Accept for a local development cluster; keep the Name Constraints mandatory (refuse to generate an unconstrained CA); document the exposure.
Status: ⚠️ PARTIAL (Name Constraints are applied when a permitted-DNS list is non-empty; the unconstrained case isn’t refused)
ADR: ADR-0006
Spec Requirement: SR-011
TM-PKI-003: A cluster-reported DNS zone flows into CA Name Constraints without validation
Element: E6 to D2
Category: Tampering, Elevation of Privilege
Evidence: CODE. When cluster.network.cluster_domain is unset, resolve() (crates/xianyun-cluster/src/clusterdomain/resolve.rs:46-90) copies the discovered zone into it (a configured value that differs returns ConfigMismatch instead); build_permitted_dns (crates/xianyun-pki/src/constraints.rs) appends .{domain}.
UNPROVEN. Measurement: with a fake DnsReader returning com, generate the CA and read its permitted subtrees. Applies to driver: external, where Xianyun doesn’t own the cluster.
DREAD: D:7 R:4 E:3 A:3 D:3 = 20 (Medium)
Mitigation: Reject single-label and public-suffix zones; require the zone to be a subdomain of a configured base domain.
Status: Not Started
ADR: ADR-0006
Spec Requirement: SR-011
TM-PKI-004: CA directories use the default umask
Element: D2
Category: Information Disclosure
Evidence: CODE. ca.key and ca.crt are created 0600 with create_new (ca.rs:525-543), but pki/ and gen-*/ come from create_dir and create_dir_all with no mode.
Description: The key file itself is private, so exposure is limited to metadata and the certificate. It matters mostly as defence in depth.
DREAD: D:3 R:6 E:3 A:2 D:4 = 18 (Medium)
Mitigation: Create the PKI directories 0700.
Status: Not Started
ADR: ADR-0006
Spec Requirement: SR-010
TM-PKI-005: Loading a CA doesn’t check that the certificate and key belong together
Element: D2
Category: Tampering
Evidence: CODE (ca.rs load, about line 560); ca.rs:735 has a test with a tautological assertion labelled “placeholder pairing check pending real crypto”.
Description: A swapped or corrupted file would be trusted and used to sign.
DREAD: D:3 R:3 E:2 A:2 D:3 = 13 (Medium)
Mitigation: Verify the public key in the certificate matches the private key at load; replace the placeholder test.
Status: Not Started
ADR: ADR-0006
Spec Requirement: FR-008
TM-PKI-006: Some trust stores may ignore Name Constraints
Element: D6
Category: Elevation of Privilege
Evidence: UNPROVEN. The CA is trusted system-wide (Linux anchors, macOS System keychain) and in NSS with flag C. Whether every client enforces permittedSubtrees varies. Measurement: issue a leaf for example.com from the generated CA and attempt a TLS connection from curl, Firefox, and Go with the CA trusted.
DREAD: D:9 R:3 E:4 A:4 D:3 = 23 (Medium)
Mitigation: Keep lifetime short (it’s currently 10 years); document which clients enforce constraints.
Status: Not Started
ADR: ADR-0006
Spec Requirement: SR-011
Kubeconfig (D4, TB5)
TM-KUBE-001: The kubeconfig backup file is world-readable
Element: D4
Category: Information Disclosure
Evidence: MEASURED. After two writes under umask 022, the kubeconfig was mode 600 and its .caproni.bak was mode 644. backup() (crates/xianyun-cluster/src/kubeconfig/file.rs:137) uses std::fs::write.
Description: The backup holds the same credentials as the live file, including client certificates and tokens for any cluster in ~/.kube/config.
Attack vector: Another local user reads ~/.kube/config.caproni.bak if the home directory permits traversal.
Affected assets: kubeconfig credentials for every cluster in the file.
DREAD: D:6 R:8 E:6 A:3 D:5 = 28 (High)
Mitigation: Create the backup with mode 0600 (copy the source mode).
Status: Not Started
ADR: ADR-0003
Spec Requirement: SR-010
TM-KUBE-002: Temporary kubeconfigs for every kubectl and helm call are world-readable
Element: D4
Category: Information Disclosure
Evidence: CODE. kubeconfig_env in crates/xianyun-backends/src/helm/mod.rs:109-114 and crates/xianyun-backends/src/kubectl/mod.rs:239-244 write the full kubeconfig with std::fs::write to $TMPDIR/xianyun-helm-kubeconfig-<pid>-<nanos> and $TMPDIR/xianyun-kubeconfig-<pid>-<nanos>. The call pattern is the same one measured for the kubeconfig backup in TM-KUBE-001 (mode 0644 under umask 022); this path wasn’t measured separately. The mirrord config uses Tempfile::new with the default mode and a PID-and-time suffix (crates/xianyun-deploy/src/reloader/mirror/manager.rs:507).
Description: Each helm or kubectl invocation leaves cluster credentials in a guessable file for as long as the command runs. It happens far more often than the one-off backup.
Attack vector: On a shared host, another user lists $TMPDIR and copies the file while a deploy runs.
Affected assets: kubeconfig credentials for the local cluster (or the external cluster for driver: external).
DREAD: D:6 R:7 E:5 A:3 D:4 = 25 (High)
Mitigation: Use tempfile::Builder (0600, random name) as kubeconfig/file.rs already does for the virtual kubeconfig.
Status: Not Started
ADR: ADR-0004
Spec Requirement: SR-010
Host files and privileged operations (P6, D5, D6, TB3)
TM-HOSTS-001: An unvalidated --ip injects arbitrary lines into /etc/hosts
Element: P2 to P6 to D5
Category: Tampering
Evidence: MEASURED at the backend: Client::update with ip_address = "1.2.3.4 x\n6.6.6.6 gitlab.com" rendered the line 6.6.6.6 gitlab.com gitlab.test. CODE for the CLI path: get_cluster_ip returns the flag verbatim (crates/xianyun-cli/src/commands/updateetchosts.rs:99) and the content is written with sudo tee /etc/hosts. The CLI-level dry run printed nothing in this environment, so the end-to-end write wasn’t observed (and wasn’t attempted).
Description: The --ip value is treated as an opaque string. A newline lets the caller add host entries that redirect real domains.
Attack vector: A prompt-injected agent calls the gated update-etc-hosts tool with a crafted --ip (the user sees a generic consequence message; see TM-MCP-004).
Affected assets: /etc/hosts.
DREAD: D:7 R:8 E:4 A:2 D:4 = 25 (High)
Mitigation: Parse --ip as std::net::IpAddr and reject anything else; reject hostnames containing whitespace in HostsFile.
Status: Not Started
ADR: ADR-0004
Spec Requirement: SR-004
TM-HOSTS-002: /etc/hosts is rewritten in place with no backup
Element: P6, D5
Category: Tampering, Denial of Service
Evidence: CODE (updateetchosts.rs:194-215). The new content is piped to sudo tee /etc/hosts, which truncates before writing.
Description: An interruption mid-write leaves a truncated hosts file. The hand-rolled parser (crates/xianyun-backends/src/hosts/mod.rs) also has to round-trip every line it didn’t touch.
DREAD: D:5 R:3 E:2 A:2 D:3 = 15 (Medium)
Mitigation: Write a temp file beside /etc/hosts and mv it over; keep a timestamped backup.
Status: Not Started
ADR: ADR-0004
Spec Requirement: FR-009
TM-HOSTS-003: Config-supplied hostnames can claim real domains
Element: P3 to P6
Category: Tampering
Evidence: CODE. The schema validates hostnames against RFC 1123, with a second anyOf branch for variable references that is unanchored (config.v2.schema.json:~83-85, \$[a-zA-Z_]|\$\{[a-zA-Z_]), so a string containing $name also passes. UNPROVEN: whether a surviving $ plus whitespace can reach HostsFile; measure by expanding such a value and rendering it.
Description: A config or remote fragment can list github.com or a bank’s hostname in additional_hostnames, and update-etc-hosts will point it at the cluster IP.
DREAD: D:6 R:7 E:3 A:3 D:4 = 23 (Medium)
Mitigation: Show the full hostname list and ask for confirmation before the first write; warn on names outside configured domains.
Status: Not Started
ADR: ADR-0009
Spec Requirement: SR-004
Registry cache (P5, TB7)
TM-REG-001: The zot registry cache listens on all interfaces without authentication
Element: P5
Category: Tampering, Information Disclosure
Evidence: CODE. crates/xianyun-deploy/src/registrycache/config.rs:120 sets address: "0.0.0.0" on port 5555; the generated config has no accessControl or authentication section (searched for both).
UNPROVEN. Measurement: start the cache, then curl -X POST http://<lan-ip>:5555/v2/test/blobs/uploads/ from another host. A success means anonymous writes are possible, which allows cache poisoning.
Description: Clusters pull images from this cache without further verification, so poisoned blobs run in the dev cluster.
DREAD: D:6 R:7 E:5 A:4 D:6 = 28 (High)
Mitigation: Bind to the loopback or bridge address the cluster uses; add read-only anonymous policy.
Status: Not Started
ADR: ADR-0007
Spec Requirement: SR-012
TM-REG-002: The zot PID file isn’t validated before signalling
Element: P5
Category: Denial of Service
Evidence: CODE. read_pid parses an i32 with no positivity check (crates/xianyun-deploy/src/registrycache/process.rs:194-201), and stop_process calls libc::kill(pid, SIGTERM) then SIGKILL. A value of 0 or -1 has process-group or all-process semantics under POSIX.
Not executed. The measurement (write -1, call stop_process) would signal every process the user owns, so it was deliberately skipped. A safe variant is a unit test over read_pid that asserts pid > 0.
Description: A stale PID is the likelier case than a hostile one: after a reboot or crash that leaves zot.pid behind, registry stop sends SIGTERM and then SIGKILL to whichever same-user process now holds that PID, and is_running treats EPERM as running. A malicious value needs same-user write access to the data directory.
DREAD: D:5 R:5 E:3 A:1 D:3 = 17 (Medium)
Mitigation: Reject pid <= 1 and confirm the process name before signalling.
Status: Not Started
ADR: ADR-0007
Spec Requirement: FR-011
TM-REG-003: The k3d mirror configuration skips TLS verification
Element: P5 to E6
Category: Spoofing
Evidence: CODE (crates/xianyun-cluster/src/driver/k3d.rs:499): insecureSkipVerify: true for the mirror endpoint. The config schema also requires an http:// mirror endpoint.
Description: The cluster-to-cache hop is unauthenticated and unencrypted. Together with TM-REG-001, anything on the path can alter pulled images.
DREAD: D:4 R:5 E:4 A:3 D:3 = 19 (Medium)
Mitigation: Accept for loopback-only mirrors; document it.
Status: Not Started
ADR: ADR-0007
Spec Requirement: SR-012
Process execution (P4, TB6)
TM-EXEC-001: Lifecycle hooks run with the full parent environment
Element: P4
Category: Information Disclosure, Elevation of Privilege
Evidence: CODE (crates/xianyun-update/src/lifecyclehooks.rs:171-185). Hooks inherit every parent variable plus resolver variables and KUBECONFIG. Hooks aren’t run through a shell, so there’s no metacharacter expansion.
Description: Inherent to the feature. It becomes a threat only when the hook’s origin is untrusted (TM-CFG-001, TM-CFG-004).
DREAD: D:6 R:7 E:2 A:2 D:3 = 20 (Medium)
Mitigation: Accept; the origin-trust controls above are what limits this.
Status: ⚠️ PARTIAL (no shell is involved; environment filtering isn’t offered)
ADR: ADR-0004
Spec Requirement: FR-013
TM-EXEC-002: Two divergent shell-word splitters parse commands
Element: P4
Category: Tampering
Evidence: CODE. lifecyclehooks.rs:193 has no backslash handling; crates/xianyun-cli/src/commands/editmodeprocesssupervisor.rs:62 does.
Description: The same string can split into different argv depending on the path. That is a correctness risk with a security flavour: reviewing a config by one parser’s rules and executing it by another’s.
DREAD: D:2 R:5 E:2 A:1 D:3 = 13 (Medium)
Mitigation: Use one splitter (the shlex crate or a shared internal function).
Status: Not Started
ADR: ADR-0004
Spec Requirement: FR-013
TM-EXEC-003: mise exec wraps repository commands and may load repository-supplied tool config
Element: P4
Category: Elevation of Privilege
Evidence: CODE (crates/xianyun-executor/src/exec/mise_decorator.rs:12-24): when mise is on PATH, every run_in_repository command becomes mise exec -- <cmd>.
UNPROVEN. Measurement: put a .mise.toml with an [env] template that runs a command into a test repository, run a hook, and see whether it executes without a trust prompt.
DREAD: D:7 R:5 E:4 A:2 D:4 = 22 (Medium)
Mitigation: Document that mise trust applies; consider an opt-out.
Status: Not Started
ADR: ADR-0004
Spec Requirement: FR-013
TM-EXEC-004: Cluster domain discovery runs an unpinned image
Element: E6
Category: Tampering
Evidence: CODE (crates/xianyun-cluster/src/clusterdomain/mod.rs:68): the discovery Job uses busybox:1.36 by tag. MEASURED 2026-10-14: it’s the first source tried, so on a cluster that can’t pull the image up, deploy, and ca inspect each wait out its 120-second timeout (GAP-009).
DREAD: D:5 R:4 E:3 A:3 D:3 = 18 (Medium)
Mitigation: Pin by digest; the NixOS VM tests already pin k3d images this way.
Status: Not Started
ADR: ADR-0008
Spec Requirement: SR-013
TM-EXEC-005: sudo and zot are resolved through PATH
Element: P4, P6
Category: Elevation of Privilege
Evidence: CODE. apply_sudo prepends the PATH-resolved sudo (crates/xianyun-executor/src/exec/mod.rs:535), and lookup_zot resolves zot the same way (crates/xianyun-deploy/src/registrycache/process.rs). Trust Boundary TB6 names PATH, but no threat did until now.
Description: A writable directory early in the developer’s PATH with a program named sudo or zot gets run, and sudo receives the tee /etc/hosts content. This is a general property of CLI tools and mostly matters on a compromised account.
DREAD: D:6 R:3 E:3 A:2 D:3 = 17 (Medium)
Mitigation: Accept; document that PATH is trusted.
Status: Not Started
ADR: ADR-0004
Spec Requirement: NFR-002
Repudiation, supply chain, and planned features
TM-AUD-001: Privileged and agent-initiated actions leave no durable audit record
Element: P1, P2, P6
Category: Repudiation
Evidence: CODE. Privileged writes log through tracing to stderr; MCP calls re-exec the binary and return captured output. Nothing records which MCP client requested what, or that the user confirmed it.
DREAD: D:4 R:8 E:3 A:2 D:5 = 22 (Medium)
Mitigation: Append one structured line per privileged operation and per MCP tool call (command, arguments, confirmation outcome) to a file in the data directory.
Status: Not Started
ADR: ADR-0005
Spec Requirement: SR-014
TM-SUP-001: No CI job runs tests or dependency scans for the Rust workspace
Element: build
Category: Tampering
Evidence: MEASURED. .gitlab-ci.yml contains no cargo or nix invocation, and no deny.toml or audit.toml exists. It does include standard-build@v5.25, whose own comment mentions “gitlab-scanners”; whether that template scans Cargo.lock wasn’t checked. The flake devShell does provide cargo-audit 0.22.2 and cargo-deny 0.20.2, and nix develop -c cargo audit on 2026-10-13 scanned 336 crates and found 0 vulnerabilities and 4 warnings: paste 1.0.15 unmaintained (RUSTSEC-2024-0436) and lru 0.12.5 unsound (RUSTSEC-2026-0253 and RUSTSEC-2026-0002), both through ratatui 0.29; and unsafe-libyaml 0.2.11 unmaintained (RUSTSEC-2024-0449), through serde_yaml.
Description: The tools exist and the scan was clean of known vulnerabilities when measured, but nothing ran it on a schedule or on merge requests, so the next advisory would go unnoticed until someone ran it by hand.
DREAD: D:6 R:5 E:3 A:5 D:4 = 23 (Medium)
Mitigation: A CI job that runs cargo test --workspace, cargo clippy, and cargo deny check, plus a committed deny.toml that records each accepted advisory with a reason.
Update 2026-10-13: .gitlab-ci.yml now has those jobs, and a scheduled pipeline that runs cargo deny check alone. deny.toml records four accepted advisories (paste and the two lru soundness advisories, all via ratatui, and unsafe-libyaml, via serde_yaml). Writing it exposed a gap: with cargo deny’s default settings the two lru advisories were silently not reported, though cargo audit reported them, because the default only checks workspace crates for unsound advisories. deny.toml sets unsound = "all" so the two tools agree. Each command passed locally. The pipeline hasn’t run on GitLab, and the schedule doesn’t exist until someone creates it.
Status: ⚠️ PARTIAL (jobs and policy committed and verified locally; not yet run in CI)
ADR: ADR-0008
Spec Requirement: NFR-007
TM-SUP-002: serde_yaml is deprecated and sits on an unmaintained parser
Element: P3
Category: Tampering
Evidence: MEASURED. Cargo.lock has serde_yaml 0.9.34+deprecated, and cargo audit flags its dependency unsafe-libyaml 0.2.11 as unmaintained (RUSTSEC-2024-0449). cargo tree -i unsafe-libyaml shows it reaches xianyun-cli, xianyun-cluster, and others through serde_yaml.
DREAD: D:3 R:3 E:3 A:5 D:5 = 19 (Medium)
Mitigation: Plan a move to a maintained YAML crate; the config tests ported from Go are the safety net.
Status: Not Started
ADR: ADR-0009
Spec Requirement: NFR-006
TM-TEL-001: Planned telemetry would carry command arguments and hook scripts
Element: future
Category: Information Disclosure
Evidence: CODE, as it stood before removal. The dormant event types included CommandEvent{args} and HookEvent{script, repository}, and the code decided whether to enable telemetry by looking for an @gitlab.com address in git config, a GitLab Zoom configuration file, or a GitLab hostname pattern. Nothing called it.
Description: Design-time risk: had the emitter been ported as written, secrets in arguments or scripts would have left the machine, and a public, application-agnostic tool would have carried a GitLab-employee heuristic.
DREAD: D:5 R:4 E:2 A:5 D:3 = 19 (Medium)
Mitigation: Eliminate the feature. The module, its event types, and its environment variables were removed on 2026-10-13.
Status: ✅ IMPLEMENTED (eliminated; SR-less, see NFR-005, 2026-10-13)
ADR: ADR-0018
Spec Requirement: NFR-005
Risk summary
| Band | Count | IDs |
|---|---|---|
| Critical | 0 | none |
| High | 12 | TM-MCP-001, TM-MCP-002, TM-MCP-003, TM-MCP-004, TM-CFG-001, TM-CFG-003, TM-CFG-004, TM-PKI-001, TM-KUBE-001, TM-KUBE-002, TM-HOSTS-001, TM-REG-001 |
| Medium | 22 | the rest |
| Eliminated | 1 | TM-TEL-001 |
Evidence breakdown across 35 threats: 7 have a MEASURED runtime observation (TM-MCP-001, TM-MCP-003, TM-MCP-005, TM-CFG-003, TM-PKI-001, TM-KUBE-001, TM-HOSTS-001), 2 more are MEASURED by file search and cargo audit (TM-SUP-001, TM-SUP-002), and the remainder are CODE or UNPROVEN with the confirming measurement named. TM-MCP-005 is measured only against the committed tool snapshot, not a live client, and TM-PKI-001 was measured at the backend rather than through xianyun up (see its note on GAP-001).
Residual risk and what wasn’t examined
- The Go binary still ships in this repository and is documented as the primary tool. Its threats overlap but aren’t analysed here.
- The macOS Keychain path (
system_darwin.rs) was read but not run. Its use ofsudo security add-trusted-certinto the System keychain deserves its own review on a real macOS host. - The Nix VM test harness, the Helm charts users deploy, and mirrord’s own cluster-side agent are out of scope.
cargo auditran against the committedCargo.lockonly;cargo denywas not run, and no licence or source policy was reviewed.- No fuzzing or dynamic testing of the YAML parser, hosts parser, or variable expander was done.
- The zot log file’s mode, and whether the
logstool can return secrets that edit-mode processes print, weren’t checked. - Time-of-check to time-of-use behaviour in
ensure_current(CA regeneration) was noted but not tested.
Changelog
| Date | Change |
|---|---|
| 2026-10-13 | Initial model, created by the all-aboard review |
Documentation review
This is the report from the all-aboard review of 2026-10-13, updated the same day after the documentation moved into this book. The first section says what changed since the first version.
Follow-up: the Xianyun book
Three decisions followed the first report:
- The documentation names
xianyunand leads with MCP. The new pages describe thexianyunbinary and put the MCP server first: connecting an agent, how the server works, the tool reference, confirmation, workflows, and security. The terminal comes second. - A separate site built with mdBook and rustdoc. Recorded in ADR-0010. The Hugo site and the
docs/directory are left for the Go binary. The ADRs, specification, stories, threat model, and this report moved intobook/src/project/, so they no longer go through Hugo and the links between them resolve. - The work went ahead. What changed:
- 48 pages in
book/src/(23 outsideproject/), including a command reference generated fromxianyun --helpbyscripts/gen-cli-reference.sh, with a--checkmode that fails when it drifts. scripts/build-docs.sh,nix build .#docs, andnix build .#xianyun-mcp. The dev shell now hasmdbookandmdbook-linkcheck2.- rustdoc builds clean under
-D warningsafter fixing 20 warnings, mostly links from public docs to private items. - The repository linter covers
book/src. book/STYLE_GUIDE.mdrecords the accessibility, theme, and voice decisions.
- 48 pages in
The pages were written from behaviour I ran, not from memory. Measured while writing them: the MCP handshake and tool calls over stdio against a mock-driver project; that a gated tool fails on a client at protocol 2025-11-25; the exact CA name constraints for a sample config ([.local .test .svc]); config validation errors; variable expansion with and without an exported variable; and the xianyun version, ca inspect, and --help output. Doing so found three things I’d gotten wrong in the spec, now fixed: the CA’s permitted names (the defaults are .local, .test, .svc, not a subtree of your hostname), a claim that up merges the kubeconfig (it fetches it; set-kube-context merges), and config validate examples that omitted the required cluster.network block. It also added GAP-008, user-facing strings that still say Caproni.
Still unchecked: the book wasn’t read in a browser here (screenshots failed in this sandbox), so layout is verified from the generated HTML only, and I didn’t test a real MCP client end to end beyond a scripted stdio session.
Follow-up: GAP-001, the end-to-end scenarios, and removing telemetry
The headline finding of the first review is closed. Every statement below was measured on 2026-10-14 unless it says otherwise.
GAP-001 is fixed. The three deployer managers now implement the deployer::Manager trait and are registered in xianyun-cli. Helm also had no undeploy, which the trait needs; it was added, test first. The e2e-deploy NixOS VM test runs xianyun up on a real k3d cluster and installs a namespace, a Kustomize overlay, and a Helm chart in dependency order. It also repeats the deploy (the Helm upgrade path), checks that --repo deploys only the named deployer, and checks that a broken chart fails the run and is named.
Five of the seven lost scenarios are back as VM tests (e2e-deploy, e2e-no-ingress, e2e-ca-drift, e2e-cells, e2e-registry-mirror), each run to a pass against the final code. The two that need edit mode are blocked by GAP-002. ADR-0019 records the design, and Testing lists what each checks and how long it takes.
Telemetry is removed (ADR-0018): the module, its environment variables, and 29 tests, with the GitLab-employee heuristic going with it.
Found by writing the tests, not by reading code:
- A command run with no logging flags logged nothing.
LoggingArgs::default()held empty strings, the logger failed to build, anddispatchdiscarded the error. Fixed, with unit tests (GAP-010). up,deploy, andca inspecteach wait about two minutes on a cluster that can’t pullbusybox:1.36, because discovery tries that probe first (GAP-009, not fixed: the order was ported from the Go tool).- Two of my own test mistakes, recorded in Testing: a control that failed for the wrong reason, and a test script the driver rejected before the VM booted.
Still open: edit mode (GAP-002, GAP-006), doctor’s missing diagnostics (GAP-003), --no-cache (GAP-005), the /etc/hosts check in up (GAP-007), the Caproni strings (GAP-008), the discovery wait (GAP-009), and the 12 High threats, none of which is mitigated. Not covered by any scenario: the builtin:dev-ca and builtin:traefik-tlsstore overlays, managed zot, and the Colima driver. Several threat entries that I marked “latent until GAP-001 is fixed” are now reachable, and the entries say so; I haven’t re-measured them through a real up.
A flaky test, found by the first CI run. registry_mirror_config_active_test in the Colima driver failed on GitLab’s runner and had never failed locally. Driver code reads SUDO_USER, several tests set it, and only the tests that set it took the environment lock, so a reader could run while it was set and take the wrong code path. Reproduced deterministically (the same panic), then fixed by having every test that builds a driver take the lock, with a regression test. 100 runs of the file passed afterwards. The same first run showed the pages job failing on my own ls | head under pipefail; fixed in the next commit.
Test counts: 1,119 passed, 0 failed, 23 ignored (1,142 in all). It was 1,137 before: 29 tests left with the telemetry code, and 11 were added. Fmt, clippy with warnings denied, and cargo deny pass.
Follow-up: removing the Go tree, and publishing
The project is meant to be public, so the Go implementation and everything that described it were removed (ADR-0017). The sections below this one describe the repository as it was when the review ran, including docs/ and the Hugo site, which no longer exist. Treat them as a record, and the remaining-gaps table as superseded by this section.
Removed: cmd/, internal/ (368 Go files), the Go module and tooling configuration, the Dockerfiles, the Hugo site and docs/, the seven Go integration suites and the one that validated examples, and the GitLab-internal template and automation configuration.
Carried over, each checked against the binary: composition, lifecycle hooks, Linux and Podman setup, troubleshooting and resets, use cases, and the architecture page. The old decision log and registry-cache design became ADR-0011 through ADR-0016.
Added: a GitLab CI pipeline (fmt, clippy, test, deny, example validation, a generated-reference check, Markdown and shell lint, a docs build, and a pages deploy), deny.toml, scripts/validate-examples.sh, and a rewritten README, CONTRIBUTING.md, and AGENTS.md. The pipeline follows the pattern in GitLab’s hardened-builds project: build and check the site in one job, publish it in another.
Found while doing it:
cargo deny’s default settings hid two soundness advisories thatcargo auditreported (thelrucrate, through ratatui).deny.tomlnow reports unsound and unmaintained advisories for every dependency, and records four accepted advisories with reasons. See TM-SUP-001.denyalso rejected the workspace because no crate declaredpublish = false; they now do.examples/own-infrastructure.yamlpassed--default-issuer-name=caproni-ca, an issuer the Rust build doesn’t create. It now saysxianyun-ca, and its header no longer carries Go-era migration advice.
Verified locally: cargo fmt --check, cargo clippy -D warnings, cargo test --workspace (1,119 passed, 0 failed, 23 ignored), cargo deny check, scripts/validate-examples.sh (15 configurations), rumdl check, shellcheck, nix build .#docs, and a sub-path serving test of the built site. GitLab accepts the pipeline file (checked with the project’s CI lint API).
Not verified: the pipeline hasn’t run on GitLab. The nixos/nix image, the shared runners’ ability to run nix develop for the whole workspace, cache sizes, job times, and the e2e:mock job’s need for KVM are all untested. The Pages site hasn’t been published, and the weekly advisory schedule doesn’t exist yet.
Original report
Date: 2026-10-13
Scope: the Rust workspace (crates/) and the documentation that describes it
Commit reviewed: d5f74650 on main, plus uncommitted changes from this run
Executive summary
The project had no specification, no ADRs, no threat model, and no user stories. What it had was 50-odd user-facing pages that describe the Go caproni binary, a decision log with two stale rows, and per-crate porting notes. This run created all four missing artifact types for the Rust workspace, fixed the clear factual errors in existing docs, and checked the new material against the source with an independent adversarial review.
The headline finding isn’t a documentation defect. xianyun up and xianyun deploy can’t install anything today, because no deployer manager is registered in production code. The NixOS VM tests don’t catch this since their configs list no deployers. Measured, not inferred: see GAP-001 in the spec.
The review also caught two mistakes in my own first drafts (a wrong variable-expansion requirement, and a confirmation-bypass tagged “unproven” when one test settles it). Both are fixed and the second is now a measurement. Everything below uses the corrected numbers.
Assessment results
| Artifact | Before | After | Status |
|---|---|---|---|
| Specs | none | 1 spec: 15 FR, 2 IR, 7 NFR, 14 SR, 7 gaps | Needs work: 5 FR Implemented, 9 Partial, 1 Gap |
| ADRs | none (9 decision-log rows, 12 embedded in a design doc) | 9 ADRs, all accepted | Needs work: 12 embedded decisions still to extract |
| Threat model | none | 35 threats: 12 High, 23 Medium, 0 Critical | Needs work: 0 of 35 mitigations implemented |
| User stories | none | 4 stories, all Revision 1 | Needs work: 6 candidates in backlog |
Work performed
Created
docs/threat-models/xianyun-threat-model.md: STRIDE per element, DREAD scored, data flow diagram, 7 trust boundaries. Each threat is tagged MEASURED, CODE, or UNPROVEN. 7 have a runtime measurement and 2 more a search or scan measurement.docs/adr/0001to0009: record-keeping, the rewrite, compatibility aliases, theCommandRunnerseam, the MCP server, the dev CA, the registry cache, VM tests, and the config model.docs/specs/xianyun-spec.md: retroactive spec with EARS requirements, Gherkin, a traceability matrix, and 7 implementation tasks.docs/stories/STORY-001to004: bring-up and deploy, agent over MCP, dev CA trust, legacy project compatibility.
Updated
docs/config/_index.md:mirror.processestomirror.edit_mode_processes(the schema field), and the fullkubernetes_typelist.docs/edit-mode-workflow.md:kubernetes_typelist.crates/README.md: replaced the test counts with figures measured today; noted the one unimplemented placeholder test.docs/rust-rewrite-macos.md: removed the duplicate heading and the “not yet implemented” and “DONE” contradiction.docs/rust-rewrite-vision.md: it said there was no MCP interface; there is.docs/contributing/decisions.md: marked the BYO-only row Rejected/Deferred and the containerd row Accepted, with dated notes pointing at ADR-0007; added a pointer todocs/adr/.docs/telemetry.md: added a warning that the Rust binary sends nothing.docs/contributing/architecture.md,docs/design/registry-cache.md: dead paths (docs/configuration.md,internal/config/v2/config.go).integration-tests/validate-examples/README.md: it described a filename filter the script doesn’t apply.AGENTS.md: one missing blank line.
Verification run
| Check | Result |
|---|---|
nix develop -c cargo test --workspace | 1,119 passed, 0 failed, 23 ignored |
nix develop -c cargo audit | 0 vulnerabilities, 4 warnings (paste, lru twice, unsafe-libyaml) |
rumdl check docs (repo config, includes link and anchor checks) | 64 files, no issues |
| Hugo site build in a temporary copy | Builds; links into the unrendered threat model resolve to /threat-models/ |
| ID cross-check script | 0 dangling TM-, FR-, NFR-, SR-, GAP-, ADR-, STORY- references; 0 orphan threats; all 35 DREAD totals and bands recompute |
| Independent adversarial review | Verdict “changes requested”; 3 blockers, 12 should-fix, plus missing attack surface; all addressed below |
Measurements were throwaway tests and temporary HOME directories, deleted afterwards. The repository has no leftover files from them. One environment repair happened outside the repo: the extracted cc-1.5.1 crate under ~/.cargo/registry was missing its src/target/ directory (the cached archive was intact), so I re-extracted that directory to get a build.
Key findings
Measured
| ID | Finding | Measurement |
|---|---|---|
| GAP-001 | No deployer managers are registered in production | list_types() returned []; new_manager failed for helm, kustomize, namespace |
| TM-MCP-001 | MCP HTTP binds 0.0.0.0 by default, no authentication | ss -ltn showed 0.0.0.0:18099 after mcp stream --port 18099 |
| TM-MCP-003 | Confirmation gate accepts a forged answer on the first call | Control call refused; a first call carrying {"confirm":{"action":"accept"}} ran xianyun down |
| TM-CFG-003 | Any exported environment variable is expanded and printed by config print | ${DEMO_SECRET_TOKEN} printed its value |
| TM-PKI-001 | CA key staged at mode 0644 in a 0755 directory (latent until GAP-001 is fixed) | Mode listing under umask 022, taken at the backend |
| TM-KUBE-001 | Kubeconfig backup is 0644 while the kubeconfig is 0600 | Two writes, stat |
| TM-HOSTS-001 | An --ip with a newline adds a host line to rendered content | Rendered output contained 6.6.6.6 gitlab.com gitlab.test |
Not measured, with the test that would settle each
TM-CFG-002 (http:// in a remote fragment’s relative reference), TM-CFG-005 (option-shaped repository), TM-CFG-006 (YAML alias bomb), TM-CFG-007 (config discovery outside home), TM-PKI-003 (hostile cluster zone), TM-PKI-006 (client enforcement of Name Constraints), TM-REG-001 (anonymous write to zot), TM-EXEC-003 (mise loading repository config). The threat model names the command or test for each. I deliberately skipped the PID -1 test (TM-REG-002) because it would signal the developer’s own processes.
Remaining gaps
| Gap | Artifact | Priority | Recommended action |
|---|---|---|---|
| Deployers can’t run (GAP-001) | Spec, code | High | Spec Task 1; add a CLI-level test and put a deployer in the Tier 2 VM config |
| 12 of 35 threats High, 0 mitigated | Threat model | High | Start with SR-001 (loopback default), SR-003 (single-use confirmations), SR-010 (private file modes), SR-004 (validate --ip) |
Go-era user docs in docs/ | docs/commands/, docs/config/, docs/getting-started/, docs/faq.md, docs/use-cases/ | Low | Now scoped to the Go binary; the book covers xianyun. Port the GitLab guide, FAQ, and use cases if they apply to Xianyun. examples/own-infrastructure.yaml still passes --default-issuer-name=caproni-ca |
| No published release | Book | Medium | The installation page builds from source; add release instructions when there’s a release |
docs/commands/ is Go-generated | docs | Low | Resolved for Xianyun: the book’s command reference is generated from the Rust binary |
doctor has 2 of 5 diagnostics (GAP-003) | Spec, code | Medium | Port or document the omission; docs/commands/doctor.md promises all 5 |
Edit-mode supervisor stub, status failing with reloaders (GAP-002) | Code | Medium | Decide the supervisor approach and record it in an ADR |
12 embedded ADRs in registry-cache.md; 7 other decision rows | ADRs | Medium | Extract into docs/adr/ and retire the matching rows |
| No ADR for FR-006, FR-012, FR-015, IR-001 | ADRs | Low | Add when those areas change |
| macOS passing count unknown | crates/README.md, macOS doc | Medium | Re-run cargo test --workspace on the macOS host and record it |
AGENTS.md and architecture.md describe Go only | Contributor docs | Medium | Add a Rust section: CommandRunner, xianyun-testutil, driver registry, PORT_NOTES.md |
Stale PORT_NOTES.md headers (“red phase”, “still blocked”), draft artefacts, misplaced H1s | crates/*/PORT_NOTES.md | Low | Clean up; treat source as authoritative meanwhile |
Stale “Not yet implemented” headers in ported modules; #![allow(dead_code)] in 3 production modules, deployer::nix/cngparity/nixgen placeholders | Code | Low | Breaks the project rule against allow(dead_code); complete or remove |
Vacuous test assertions (an always-true or and a tautological length check) | xianyun-pki | Medium | Replace with real assertions; TM-PKI-005 depends on one |
| No Rust CI job (TM-SUP-001) | CI | High | Spec Task 6; commit a deny.toml that records the four accepted audit warnings |
| Hugo link handling | doc-site | Low | See “Decisions for you” |
| Hextra theme submodule not checked out | doc-site/themes/hextra | Low | git submodule update --init before local site builds |
| Design docs lack status markers and describe commands that never existed | docs/design/*.md | Low | Add a status line to each |
| Extensionless Hugo links break on GitLab | docs/getting-started/* | Low | Use .md links |
Cross-reference health
Links were verified by rumdl (target file and anchor must exist) and an ID script. Counts are links in the new artifacts only.
| Link type | Total | Valid | Broken | Missing |
|---|---|---|---|---|
| Spec to ADR | 10 | 10 | 0 | 0 |
| Spec to threat model | 26 | 26 | 0 | 0 |
| ADR to spec | 22 | 22 | 0 | 0 |
| ADR to threat model | 31 | 31 | 0 | 0 |
| Threat model to spec | 36 | 36 | 0 | 0 |
| Threat model to ADR | 35 | 35 | 0 | 0 |
| Story to spec and ADR | 18 | 18 | 0 | 0 |
“Missing” counts links that should exist and don’t. The spec’s own matrix lists the known gaps: FR-005 has no CLI-level test, FR-006, FR-012, FR-015, and IR-001 have no ADR, and NFR-005 and NFR-006 have no test.
Artifact status summary
| Artifact type | Total | Implemented | Partial | Not started | Blocked by |
|---|---|---|---|---|---|
Threat model entries (TM-*) | 35 | 0 | 3 | 32 | Iterate P0 and P1 |
Stories (STORY-*) | 4 | 0 | none | 4 (Revision 1) | Iterate P1 |
Security requirements (SR-*) | 14 | 0 | 4 | 10 | Iterate P0 |
| ADRs (accepted) | 9 | 8 list implementation files, all of which exist (ADR-0001 is process-only) | none | 0 | none |
Spec functional requirements (FR-*) | 15 | 5 | 9 | 1 (Gap) | Iterate P1 |
| Spec tasks with planned files | 7 | 0 | none | 7 | Iterate P1 |
Statuses use the values the iterate skill parses. Stories stay Revision 1 until their scenarios have passing tests. Four of the scenarios fail today (STORY-001’s first two, STORY-002’s fourth, STORY-003’s third).
Story gap map
| Candidate story | Source | Priority | Status |
|---|---|---|---|
| Developer brings up a cluster and deploys a chart | FR-004, FR-005 | High | Created: STORY-001 |
| Agent validates over MCP with gated destructive operations | FR-014 | High | Created: STORY-002 |
| Developer trusts the dev CA locally | FR-008 | High | Created: STORY-003 |
| Existing Caproni project works unmodified | FR-001 | High | Created: STORY-004 |
| Developer runs edit mode with a reloader | FR-006 | Medium | Backlog |
| Developer updates repositories | FR-007 | Medium | Backlog |
| Developer tears down or destroys a cluster | FR-004 | Medium | Backlog |
Developer updates /etc/hosts | FR-009 | Medium | Backlog; needs a failure scenario for TM-HOSTS-001 |
| Registry cache lifecycle | FR-011 | Low | Backlog |
doctor diagnoses environment problems | FR-012 | Low | Backlog |
Story review (story-reviewer rubric, applied once)
| Story | Verdict after revision | Notes |
|---|---|---|
| STORY-001 | Revision 1 | Security mapping widened to all six STRIDE categories; derived-test table added. First two scenarios fail until GAP-001 is fixed. |
| STORY-002 | Revision 1 | Autonomy fields aren’t concrete: the action cap is an open decision, recorded as a gap instead of an invented number. Dependency on STORY-001 removed. |
| STORY-003 | Revision 1 | Repudiation and denial of service rows added. Third scenario fails today. |
| STORY-004 | Revision 1 | It had no failure scenario; one was added and measured. |
The rubric limit is three review cycles; this was cycle one. I didn’t re-score after revising, so a second pass is still owed.
Decisions for you
- Which binary do the user docs describe? Resolved: the book describes
xianyun;docs/stays as the Go documentation. The guide pages cover the dev CA, registry cache, edit mode, and host setup, but not everythingdocs/does (the GitLab guide, the FAQ, and the use cases have no equivalent yet). - Should the new artifacts publish to the Hugo site? Resolved: no. They moved to the mdBook (see the follow-up above). The threat model is published with the book, because the ADRs and specification link to it; if you’d rather keep it unpublished, those links need to go first.
- How to treat the High threats. SR-001 and SR-003 are small changes with a large effect and have measured evidence, so they’re the cheapest place to start.
- Should I commit this? Nothing is committed. The repository requires Conventional Commits,
pre-commit, and the Default MR template, andno-commit-to-branchblocksmain, so it needs a branch.
Maintenance recommendations
- Update a threat’s
**Status:**line when its mitigation merges, with the SR reference and date, and move the matching SR toImplemented. - Re-run the measurements in “Not measured” before closing a threat as unreal. Spec Tasks 3, 4, and 7 turn existing throwaway measurements into committed tests, which is the durable fix.
- Add a new ADR rather than editing an accepted one. ADR-0005 and ADR-0007 each name a follow-up.
- Run
/iterateto validate artifact statuses against the implementation. The completion script cross-checks every TM entry, story, SR, ADR, and spec task against the codebase. - Run this skill again after GAP-001 is fixed and after the user docs are rewritten.
What this review did not cover
The Go implementation, the macOS Keychain code (read, not run), the Helm charts users deploy, mirrord’s cluster-side agent, cargo deny, licence policy, and any fuzzing. Test coverage (about 90 percent in crates/README.md) wasn’t re-measured. Line numbers cited in the threat model were spot-checked by the reviewer and found accurate to within a few lines, but they will drift as the code changes.
Building the documentation
This book is built with mdBook, and the API reference with rustdoc. The two are combined into one site, with the rustdoc output under api/.
One command
$ nix develop -c scripts/build-docs.sh
The script does five things:
- Builds the
xianyunbinary. - Regenerates
book/src/cli/reference.mdfrom the binary’s--helpoutput. - Runs
mdbook build book. - Checks the book’s links with
mdbook-linkcheck2. - Runs
cargo doc --workspace --no-depswith rustdoc warnings denied, and copies the result tobook/build/html/api/.
When CI_PAGES_URL is set, the script passes its path to mdBook as the site URL, so the 404 page works under a project path such as /xianyun/.
Open book/build/html/index.html in a browser. For a live preview of the prose only, run mdbook serve book; the API links won’t resolve until step 4 has run once.
Nix
$ nix build .#docs
$ xdg-open result/index.html
The same site, built in the Nix sandbox. It uses the committed command reference rather than regenerating it.
Publishing
GitLab CI builds and publishes the site; see .gitlab-ci.yml.
- The
docsjob runsscripts/build-docs.shon merge requests and on the default branch, and keepsbook/build/htmlas an artifact. A broken link, a stale command reference, or a rustdoc warning fails the merge request. A reviewer can download the artifact to read the result. - The
pagesjob runs on the default branch only. It moves that artifact topublic/, which GitLab Pages serves. It doesn’t rebuild anything, so what’s published is exactly what was checked.
The site’s address is https://jhebden.gitlab.io/xianyun/. Enable Pages for the project under Deploy > Pages if it isn’t already, and check that access control matches what you want: a project can be public while its Pages site is limited to members.
I verified the site serves correctly from a sub-path by building with CI_PAGES_URL set and requesting pages under /xianyun/ from a local server. The pipeline itself hasn’t run on GitLab.
Checks
$ scripts/gen-cli-reference.sh target/debug/xianyun --check
$ nix develop -c rumdl check
$ scripts/validate-examples.sh
The first fails when the committed command reference is out of date. The second runs the repository’s Markdown linter, including its link and anchor checks. The third validates every example configuration with the built binary.
Writing
Pages are plain Markdown. Write in sentence case, use contractions, and keep commands in console blocks and file contents in blocks tagged with their language. Name the xianyun binary, not the Go one. Don’t quote a number you haven’t measured; the project’s habit is to say how a claim was checked. book/STYLE_GUIDE.md has the details, including the accessibility and theme decisions.