6ecb2c366f
Publish snapshot / snapshot (push) Successful in 1m2s
[fix] default parameter run records parameters in session for replay[fix] remove default parameters for several cubes
264 lines
12 KiB
Markdown
264 lines
12 KiB
Markdown
# Cube bundles
|
|
|
|
How to package cubes as an npm package so other projects can install them, and
|
|
what changes once a cube lives in `node_modules` instead of in your own tree.
|
|
|
|
If you only want to *use* a published bundle, you need one line of config:
|
|
|
|
```json
|
|
{ "cubePackages": ["@bitsquare/cubes-core"] }
|
|
```
|
|
|
|
The rest of this document is for writing one.
|
|
|
|
- [What a bundle is](#what-a-bundle-is)
|
|
- [The package manifest](#the-package-manifest)
|
|
- [Writing the cubes](#writing-the-cubes)
|
|
- [Ids are claimed globally](#ids-are-claimed-globally)
|
|
- [An installed bundle is read-only](#an-installed-bundle-is-read-only)
|
|
- [How resolution actually works](#how-resolution-actually-works)
|
|
- [Publishing](#publishing)
|
|
- [Troubleshooting](#troubleshooting)
|
|
|
|
## What a bundle is
|
|
|
|
An ordinary npm package that ships cube directories and points at them from its
|
|
own `package.json`. There is no build step, no plugin API and no entry point —
|
|
nopy reads the directories off disk and imports each `manifest.mjs` directly.
|
|
|
|
```
|
|
@acme/cubes-web
|
|
├── package.json nopy.cubes → ["./cubes"]
|
|
├── README.md
|
|
└── cubes/
|
|
├── nginx/
|
|
│ ├── manifest.mjs
|
|
│ └── deploy.py
|
|
└── certbot/
|
|
├── manifest.mjs
|
|
└── deploy.py
|
|
```
|
|
|
|
`@bitsquare/cubes-core` in this repository is the worked example, and is consumed
|
|
by this repository through exactly the mechanism described here — it is not
|
|
special-cased.
|
|
|
|
## The package manifest
|
|
|
|
```json
|
|
{
|
|
"name": "@acme/cubes-web",
|
|
"version": "1.0.0",
|
|
"type": "module",
|
|
"nopy": { "cubes": ["./cubes"] },
|
|
"files": ["cubes", "!cubes/**/*.log", "README.md", "LICENSE"],
|
|
"publishConfig": { "access": "public" },
|
|
"dependencies": {
|
|
"@bitsquare/nopy-cube": "^1.0.0",
|
|
"zod": "^4.4.3"
|
|
}
|
|
}
|
|
```
|
|
|
|
**`nopy.cubes`** is the only field nopy requires. It is an array of directories,
|
|
relative to the package root, each scanned recursively for cubes. Several
|
|
entries are fine; a single `["./cubes"]` is the norm. Every entry must exist and
|
|
must stay inside the package — a path escaping the root is refused, not resolved.
|
|
|
|
**`type: "module"`** matters: manifests are ESM. Without it a `manifest.mjs` still
|
|
loads (the extension carries the day), but anything it imports relatively will
|
|
not behave the way you expect.
|
|
|
|
**`files`** decides the tarball. Note the negation: a cube that has been run
|
|
leaves a `pyinfra-debug.log` next to its `deploy.py`, and `.gitignore` has no
|
|
effect on what npm packs. Check with `npm pack --dry-run` before publishing.
|
|
|
|
**Dependencies** are `@bitsquare/nopy-cube` and `zod`, both real dependencies
|
|
rather than peers — a bundle is a leaf, and the copies it gets are the copies its
|
|
manifests use. Do **not** depend on `@bitsquare/nopy`: the CLI is what installs
|
|
your bundle, not the other way round, and depending on it invites two copies of
|
|
the same code into one tree.
|
|
|
|
## Writing the cubes
|
|
|
|
A cube directory holds a manifest (`manifest.mjs` or `*.manifest.mjs`) and a
|
|
deploy script (`deploy.py` or `*.deploy.py`). Anything else in the directory is
|
|
invisible to the loader but readable from the script, which runs with the cube
|
|
directory as its working directory.
|
|
|
|
```javascript
|
|
// cubes/nginx/manifest.mjs
|
|
import { Manifest } from '@bitsquare/nopy-cube';
|
|
import { z } from 'zod';
|
|
|
|
export default Manifest({
|
|
id: 'web:nginx',
|
|
name: 'Install and configure nginx',
|
|
dependencies: () => ['apt:essentials'],
|
|
secrets: ['TLS_KEY'],
|
|
schema: z.object({
|
|
SERVER_NAME: z.string().describe('Server name').default('example.com'),
|
|
TLS_KEY: z.string().describe('TLS private key (PEM)').default(''),
|
|
HTTP2: z.boolean().describe('Enable HTTP/2').default(true),
|
|
}),
|
|
});
|
|
```
|
|
|
|
```python
|
|
# cubes/nginx/deploy.py
|
|
from pyinfra import host
|
|
from pyinfra.operations import apt, files
|
|
|
|
SERVER_NAME = host.data.SERVER_NAME
|
|
|
|
apt.packages(name='Install nginx', packages=['nginx'], _sudo=True)
|
|
```
|
|
|
|
Import **`@bitsquare/nopy-cube`**, not `@bitsquare/nopy`. It is types and a
|
|
factory with zod as its only peer — no CLI, no prompts, no process spawning — so
|
|
your bundle stays a leaf. (`@bitsquare/nopy` re-exports the same surface as
|
|
`cubes.Manifest`, which is what older manifests use. It still works; it just
|
|
drags the CLI into your dependency graph if you declare it.)
|
|
|
|
Four things the schema is load-bearing for:
|
|
|
|
- **`.describe()` is the prompt label.** A field without one prompts with its raw
|
|
key.
|
|
- **`.default()` makes the field optional.** A field with no default is required,
|
|
and is re-prompted on replay if a session has no value for it.
|
|
- **Every schema key reaches pyinfra** as `--data KEY=value`, so `host.data.KEY`
|
|
is always defined. pyinfra parses the values itself: `"true"` arrives as a
|
|
bool, `"8080"` as an int.
|
|
- **`secrets` names keys whose values must not be persisted.** They are excluded
|
|
from session files and history, masked wherever a command is printed, and
|
|
re-prompted on replay. Naming a key that is not in the schema is a load error.
|
|
A secret is still visible in `ps` while pyinfra runs — masking covers nopy's
|
|
own output, not the process table — so treat it as protection against writing
|
|
credentials to disk, not as protection against a shared host.
|
|
|
|
`dependencies` is a function of the *collected* variables, so it can branch on
|
|
what the user actually answered, and it may pass parameters:
|
|
|
|
```javascript
|
|
dependencies: (v) => (v.HTTP2 ? ['apt:essentials', ['web:tls', { MODE: 'strict' }]] : []),
|
|
```
|
|
|
|
`before` / `after` hooks get a context whose `exec(id, vars)` pulls in any cube
|
|
by id, declared dependency or not. See [HOOKS.md](HOOKS.md).
|
|
|
|
## Ids are claimed globally
|
|
|
|
An id is claimed across every source at once — `cubeDirs`, `.npcubes` trees and
|
|
every installed bundle share one flat namespace. Two cubes claiming the same id
|
|
abort the run with an error naming both and where each came from.
|
|
|
|
There is no precedence and no shadowing, deliberately, in either direction: a
|
|
local cube does not quietly win over a packaged one, and installing a second
|
|
bundle cannot silently change what an existing id deploys. Overriding a cube from
|
|
a bundle is not a supported operation; fork the cube under your own id instead.
|
|
|
|
So prefix distinctly. `@acme/cubes-web` claiming `nginx` is asking for trouble the
|
|
first time someone installs a second bundle; `web:nginx` is not. Ids need not
|
|
mirror the directory layout — `cubes/network/tailscale` declares `net:tailscale`
|
|
— so the prefix is free.
|
|
|
|
An id is also the session key. Renaming one silently invalidates every recorded
|
|
session that used it, so treat a rename as a breaking change of the bundle.
|
|
|
|
## An installed bundle is read-only
|
|
|
|
Under pnpm, installed files are **hardlinked into a global store shared by every
|
|
project on the machine**. A cube that writes next to its own `deploy.py` does not
|
|
just dirty one `node_modules` — it corrupts that store for every other project.
|
|
|
|
Write to `/tmp`, to a path the user configured, or to the remote host. Never to
|
|
the cube's own directory. Files the cube needs to *read* (templates, config
|
|
fragments, systemd units) are fine and are exactly what the cube directory is for
|
|
— `deploy.py` runs with it as the working directory, so `files.template('nginx.conf.j2', ...)`
|
|
resolves.
|
|
|
|
This is the one constraint that does not exist while the cubes live in your own
|
|
repo, which makes it the one most likely to be discovered late. Test against an
|
|
installed copy, not a linked one.
|
|
|
|
## How resolution actually works
|
|
|
|
Worth knowing, because two of the failure modes are otherwise baffling.
|
|
|
|
**Where a package is looked up from.** Each `cubePackages` entry is resolved from
|
|
the directory of the config file that named it, not from the working directory.
|
|
Configs merge upward, so a `.nopyrc.json` two levels up can name a bundle that
|
|
only exists in *its* `node_modules`, and it resolves. The lookup reads
|
|
`package.json` off disk via `createRequire(...).resolve.paths()` rather than
|
|
going through `exports` — a bundle ships directories and has no entry point to
|
|
declare.
|
|
|
|
**Why the loader does not simply scan `node_modules`.** It cannot: pnpm plants a
|
|
symlink at `node_modules/<name>`, and `readdir` reports it as a symlink, not a
|
|
directory, so a recursive scan skips every package silently. Naming packages
|
|
explicitly is the fix, and it is also the reason `node_modules` is skipped during
|
|
the cube scan itself.
|
|
|
|
**How a manifest finds its imports.** Ordinary Node resolution, from the
|
|
manifest's own directory. An installed bundle has its own `node_modules` with
|
|
`@bitsquare/nopy-cube` and `zod` in it, so this just works. A hand-written cube
|
|
sitting in a directory with no `node_modules` would historically fail with
|
|
`ERR_MODULE_NOT_FOUND`; nopy now registers a resolve hook that catches exactly
|
|
that case and falls back to resolving `@bitsquare/nopy-cube`, `@bitsquare/nopy`
|
|
and `zod` from the running CLI. Normal resolution is always tried first, so a
|
|
cube that ships its own zod keeps it. Treat the hook as a convenience for local
|
|
cubes — a published bundle must declare its dependencies properly.
|
|
|
|
**Two copies of zod is a real hazard.** `instanceof` comparisons fail across
|
|
copies, which is why nopy inspects schemas structurally (`schema.def.type`) and
|
|
why `secrets` is a plain array rather than `.meta()` metadata — zod's metadata
|
|
registry is per-copy, and a marker written into one copy's registry is invisible
|
|
to another. Keep your zod range compatible with the CLI's (`^4.4.3`) and the
|
|
package manager will usually give you one copy.
|
|
|
|
## Publishing
|
|
|
|
Nothing bundle-specific: `npm publish` (or `pnpm publish`) with a version bump.
|
|
Some things worth deciding once:
|
|
|
|
- **Version the bundle independently of nopy.** There is no compatibility check
|
|
between the two — the loader reads whatever `nopy.cubes` points at. Document
|
|
the nopy version you test against in your README.
|
|
- **Renaming or removing an id is breaking.** It invalidates recorded sessions
|
|
and breaks any manifest listing it as a dependency, including manifests in
|
|
other people's bundles.
|
|
- **Changing a schema key is breaking** in the same way; adding one with a
|
|
`.default()` is not.
|
|
- **Test the installed shape, not the linked one.** `npm pack`, install the
|
|
tarball into a throwaway directory with a `.nopyrc.json` naming it, and deploy
|
|
from it. This is what catches a missing file, a cube that writes to its own
|
|
directory, and an undeclared dependency — none of which show up while the
|
|
package is symlinked into the repo that wrote it.
|
|
|
|
For an unattended check, replay a session file rather than reaching for `-P`
|
|
alone, which still opens the interactive picker:
|
|
|
|
```sh
|
|
nopy install -l session.json -P -D
|
|
```
|
|
|
|
Note that a replay re-prompts for anything a manifest lists in `secrets` —
|
|
those are never written to a session — so pick a cube without them, or put the
|
|
values under `env` in `.nopyrc.json`.
|
|
|
|
For how this repository releases its own packages, see
|
|
[README.PUBLISH.md](../../../README.PUBLISH.md).
|
|
|
|
## Troubleshooting
|
|
|
|
| Symptom | Cause |
|
|
| --- | --- |
|
|
| `Cube package 'X' is not installed (looked up from …)` | Not installed, or installed somewhere other than the config that named it. The path in the message is where the lookup started. |
|
|
| `Cube package 'X' declares no cubes` | Missing or malformed `nopy.cubes` in the package's `package.json`. It must be a non-empty array of strings. |
|
|
| `'./cubes' does not exist in …` | The directory was not packed. Check `files` and `npm pack --dry-run`. |
|
|
| `'…' points outside the package` | A `nopy.cubes` entry escaping the package root. Not allowed. |
|
|
| `Duplicate cube id 'X' from N sources:` | Two or more cubes claiming one id; the message lists each source. Rename one — there is no precedence rule to lean on. |
|
|
| `ERR_MODULE_NOT_FOUND` for `zod` or `@bitsquare/nopy-cube` | The bundle did not declare them as dependencies. The resolve-hook fallback covers loose local cubes, not published packages. |
|
|
| `Invalid manifest in …: 'secrets' names X, which is not in the schema` | A `secrets` entry with no matching schema key — usually a typo or a renamed field. |
|
|
| Cubes work linked, fail installed | Almost always a write into the cube's own directory, or a file missing from `files`. |
|