Files
ansiblings/packages/nopy/docs/FACTS.md
T

349 lines
14 KiB
Markdown

# Requirements and facts
A proposal, not a record. Nothing here is built.
The question it answers: a cube should be able to say *"in order to run, a user
named xyz must exist, with fish as its shell, in these groups"* — and the engine
should check that against the real host rather than blindly running whatever
cube happens to create such a user.
Everything under [What pyinfra actually gives us](#what-pyinfra-actually-gives-us)
was measured against pyinfra **3.5.1**. Everything under
[The proposal](#the-proposal) is design.
## Contents
- [The problem with `dependencies`](#the-problem-with-dependencies)
- [What pyinfra actually gives us](#what-pyinfra-actually-gives-us)
- [The proposal](#the-proposal)
- [Why `facts.py` exposes a function, not a script](#why-factspy-exposes-a-function-not-a-script)
- [Execution model](#execution-model)
- [Sharp edges](#sharp-edges)
- [Measured vs. assumed](#measured-vs-assumed)
---
## The problem with `dependencies`
`manifest.dependencies` conflates two things that are not the same:
- **a requirement** — "this cube needs a user xyz to exist"
- **a remedy** — "therefore run `user:add`"
Today only the remedy is expressible. `user:add` declares
`dependencies: () => ['apt:essentials']`, which means *always run
`apt:essentials`*, on every host, forever, regardless of whether anything it
installs is missing.
### What that actually costs
Be honest about this, because it is smaller than it first looks and it changes
what the feature is for.
pyinfra operations are **already fact-diffed**. `server.user(present=True,
shell=…)` gathers `server.Users` itself and no-ops when the state matches. The
current design is therefore not *incorrect* — it is idempotent. What it costs is:
1. **Prompts.** Resolving `user:add` drags `apt:essentials` into the plan *and
its variables into the prompt sequence*. The user is asked questions about a
cube they never chose. This is the real, visible tax.
2. **Time.** A no-op pyinfra run is still a connection, a fact gather, and an
operation build.
3. **Legibility.** The plan cannot say *"user xyz already exists, skipping"*.
This matters because the obvious objection to the whole feature is *"just write
`if not host.get_fact(…)` inside `deploy.py` — that is idiomatic pyinfra"*. The
answer is that nopy's layer is **planning and prompting**: deciding which cubes
enter the plan and which variables to ask for, before anything runs. A condition
inside `deploy.py` cannot help with either. That is the justification for lifting
facts into the Node runtime at all — and it means the feature buys **legibility
and prompt-avoidance, not correctness**.
## What pyinfra actually gives us
### The `fact` subcommand is not a machine interface
`pyinfra @local fact server.Groups` prints JSON — and prints it to **stderr**,
via `click.echo(jsonify(…), err=True)` in `pyinfra_cli/prints.py:print_fact`,
interleaved with `--> Loading config...` progress lines. Worse,
`_run_fact_operations` wraps each fact in `except PyinfraError: pass`, so the
command **exits 0 whether or not the fact resolved**.
Parsing that stream means fishing a JSON blob out of styled log output and
having no exit code to check. Rejected.
### A deploy file that prints to stdout is clean
pyinfra deploy files execute on the control machine at *build* time, and
`host.get_fact()` is available there — it is exactly how operations do their own
diffing. A file that gathers facts, prints JSON, and declares no operations
works:
```python
import json, sys
from pyinfra import host
from pyinfra.facts.server import Users, Which
users = host.get_fact(Users)
u = users.get(host.data.USER)
print("###NOPY-FACTS###" + json.dumps({
"host": host.name,
"exists": u is not None,
"shell": (u or {}).get("shell"),
"groups": (u or {}).get("groups", []),
}), file=sys.stdout)
```
```
$ pyinfra @local -y --data USER=someone facts.py
EXIT=0
--- STDOUT ---
###NOPY-FACTS###{"host": "@local", "exists": false, "shell": null, "groups": []}
--- STDERR ---
--> Loading config...
…
--> Results:
Operation Hosts Success Error No Change
Grand total - - - -
```
Confirmed properties:
- **stdout is exclusively ours.** Every byte pyinfra emits goes to stderr, which
is the same reason nopy's own logging goes there (`configureLogtape`).
- **`--data` flows in identically** to a deploy script. Parameterising a probe
is free.
- **Zero operations is legal.** `Grand total -`, exit 0.
- **`host.name` is available**, so multi-host output is self-tagging.
- **A custom `FactBase` subclass in a sibling module imports fine** under
`--chdir`, so a cube can ship fact classes pyinfra does not have.
- **An exception exits 1** with a traceback on stderr.
### The trap
**An unreachable host exits 0 and prints nothing.**
```
$ pyinfra nonexistent.invalid.example -y probe.py
nonexistent.invalid.example is neither an inventory file, a (list of) hosts…
EXIT=0
--- STDOUT --- (empty)
```
Absence of a probe result must be a hard failure. It must never be read as
"this host has no requirements to check", which is the shape the bug would take.
The `###NOPY-FACTS###` sentinel exists for this: one line per host is *required*,
and a missing line is an error.
### `server.Users` already covers the motivating case
It returns `shell`, `groups`, `home`, `uid`, `gid`, `comment` and `password` per
user, keyed by name. `user:add` needs almost no custom fact code — see
[Sharp edges](#sharp-edges) for why `password` is a problem.
## The proposal
Split the one idea into two manifest fields, because there are two different
objects: what a cube can **report** about a host (owned by the cube responsible
for that state) and what a cube **demands** (owned by the consumer).
A single `facts:` field cannot be both.
### `provides` — on the cube that owns the state
```js
// cubes/user/add/manifest.mjs
export default Manifest({
id: 'user:add',
provides: {
/** What the probe returns. Validated on the way back in. */
schema: z.object({
exists: z.boolean(),
shell: z.string().nullable(),
groups: z.array(z.string()),
}),
/** Schema keys the probe needs in order to look anything up. */
params: ['USER'],
},
schema: z.object({ /* … unchanged … */ }),
});
```
Plus a `facts.py` in the cube directory, discovered by convention exactly as
`deploy.py` is.
### `requires` — on the consumer
```js
requires: (vars) => [{
cube: 'user:add',
with: { USER: vars.DEPLOY_USER },
expect: z.object({
exists: z.literal(true),
shell: z.literal('/usr/bin/fish'),
groups: z.array(z.string()).refine((g) => g.includes('docker')),
}),
}],
```
Three deliberate choices:
**A requirement names its provider.** If `requires` stated only a predicate, the
engine would have to search the cube space for something that satisfies an
arbitrary Zod schema. That is a planner, and a planner is a research project.
Naming the cube keeps resolution linear and keeps the failure message legible.
**`requires` is `(vars) => …`, like `dependencies` already is.** A requirement
almost always depends on the consumer's own variables — you cannot know *which*
user to check for until the consumer has been asked.
**Zod is the predicate language.** It is already this repo's schema vocabulary,
`z.literal` and `.refine()` cover the cases, and `z.treeifyError()` produces the
failure report for free. The cost is that `.refine()` closures are not
serialisable, so a requirement can never be written into a session — only its
*result* can. See [Sharp edges](#sharp-edges).
### The engine's rule
This is the half of the design that "only run if requirements are fulfilled"
leaves out. When a requirement is **not** met, there are three possible answers:
| | |
| --- | --- |
| **Abort** | Honest, and useless. On a bare host nothing is fulfilled, so every fresh deploy fails. |
| **Skip the cube** | A silent no-op. Dangerous. |
| **Remedy** | Run the named cube. |
It has to be *remedy* — and remedy means "run the cube that provides it", which
is `dependencies` again. That is the actual insight here, and it is a much
smaller change than a parallel subsystem:
> **You do not need a new mechanism. You need dependencies to become conditional.**
So:
- requirement **met** → the provider is *not* scheduled and its variables are
*not* prompted for. This is where the prompt tax disappears.
- requirement **unmet** → the provider is scheduled with `with` applied as
overrides. `with` assigns at origin `param`, which already outranks every
other origin (`default < env < session < prompt < param`), so no new
precedence rule is needed.
## Why `facts.py` exposes a function, not a script
The obvious design is symmetry: `deploy.py` is a script, so `facts.py` is a
script. Reject it.
A recursive resolution over a dependency tree issues one probe per (cube, host,
params). At one pyinfra invocation each, that is one SSH connection each —
seconds apiece, multiplied by the tree. Unaffordable.
But probes are **pure reads with no ordering constraints between them**, which
means every probe for a given host can be gathered in a *single* pyinfra
invocation: one generated driver script that imports each cube's `facts.py`,
calls it with its params, and emits one JSON object per host. One connection per
host per resolution round, instead of one per cube.
A standalone script can only be run alone. A function can be batched:
```python
# cubes/user/add/facts.py
from pyinfra.facts.server import Users
def gather(host, params):
u = host.get_fact(Users).get(params["USER"])
return {
"exists": u is not None,
"shell": (u or {}).get("shell"),
"groups": (u or {}).get("groups", []),
}
```
Consequence: params arrive as one `--data NOPY_PROBES=<json>` blob rather than
per-cube `--data KEY=…`, since a batched run carries several cubes' params at
once. Probe results are memoised per (cube, host, params) in the `BuildContext`,
the same shape as the existing `resolvedCubes` set.
## Execution model
Probes run during **resolution**, before anything has deployed. That has a
consequence worth stating plainly rather than discovering later:
When the plan reads *"user absent → schedule `user:add` → then B"*, B's
requirement was never verified. It was **assumed** met because a remedy was
scheduled. Every tool in this space stops there.
Since the probe already exists, the loop can be closed for nearly nothing:
> **Re-run the provider's probe after it deploys, and fail loudly if the
> requirement still is not met.**
`facts.py` then serves as a post-condition test as well as a precondition check.
This is the single most valuable property of the design — it is strictly more
than Ansible's `when:` offers — and it should be built in from the start rather
than added as a later refinement.
Sketch of the resolution change in `BuildContext.resolveCube`, between steps 3
(`before` hooks) and 4 (dependencies):
1. Collect `manifest.requires?.(currentVars)`.
2. Batch every unmet-so-far probe for this host into one pyinfra run.
3. Parse each result against the provider's `provides.schema`, then against the
consumer's `expect`.
4. For each failure, `resolveCube(req.cube, host, req.with)`.
5. After that provider's deploy call executes, re-probe and assert.
Step 5 does not fit the current shape: `deployCalls` are all built first and
executed later, so a post-condition needs the executor to call back into
probing. That is the one structurally invasive part of this proposal.
## Sharp edges
**`--print-only` purity.** A dry run touches no host today. Probing breaks that
outright. Needs an explicit answer — either a probe-free plan that renders
requirements as unevaluated, or an opt-in flag. Silently connecting during what
the user believes is a dry run is not acceptable.
**Secret leakage.** `server.Users` returns the **encrypted password hash** in
its `password` field. A probe result that reaches history, a plan dump, or a log
line is a credential leak. The existing `secrets` machinery is per-*variable*
and does not apply — facts need their own redaction rule. Easy to miss, hard to
take back.
**Sessions must never persist facts.** Facts are host state at a moment in time;
a replay must re-probe, never restore. Recording them in *history* for
diagnostics is fine and useful. This is also forced by `.refine()` being
unserialisable.
**Sudo.** Many useful facts require `_sudo`, and a probe runs before the deploy
has made any of its own auth decisions. Unresolved — worth a spike.
**Cycles.** `requires` introduces a second edge type into a graph that still has
no cycle detection (see *Known drift* in `CLAUDE.md`). Conditional edges make
"A requires B, B requires A under different params" considerably more reachable
than the current unconditional ones do.
**Naming.** `provides` / `requires` over the original `facts:`, because the
field names should say which side of the relationship they belong to.
## Measured vs. assumed
Measured against pyinfra 3.5.1, on this machine:
- `fact` subcommand writes JSON to stderr and exits 0 on fact failure
- a deploy file's stdout is uncontaminated by pyinfra output
- `--data` reaches a probe as `host.data.KEY`
- a deploy file with zero operations succeeds
- a custom `FactBase` in a sibling module resolves under `--chdir`
- an exception in a deploy file exits 1
- **an unreachable host exits 0 with empty stdout**
Assumed, not yet tested:
- batching several cubes' probes into one driver script works and is meaningfully
cheaper than N invocations — this is the load-bearing cost assumption and
should be the first thing spiked
- `host.get_fact()` accepts `_sudo` from within a probe function
- the post-condition re-probe can be threaded through `executeDeployCalls`
without unpicking the build-then-execute split