hardening and bugfixing prior to stable release
This commit is contained in:
@@ -0,0 +1,348 @@
|
||||
# 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
|
||||
Reference in New Issue
Block a user