349 lines
14 KiB
Markdown
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
|