@biffo/cli 0.199.0 → 0.199.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/_skeletons/plugin-template/README.md +28 -1
- package/_skeletons/plugin-template/biffo.plugin.json +1 -1
- package/_skeletons/plugin-template/src/example_plugin/plugin.py +60 -29
- package/_skeletons/plugin-template/tests/test_example_plugin.py +25 -14
- package/dist/index.js +43 -11
- package/package.json +1 -1
|
@@ -55,9 +55,36 @@ engine** (`services/_plugins/orchestrator/` in the biffo-template monorepo) —
|
|
|
55
55
|
class, same layout, and it ships a real `terraform/` too. It has considerably
|
|
56
56
|
more moving parts (workflow definitions, actions, SigV4 calls into the Core
|
|
57
57
|
API); this template's `example_plugin` is deliberately the smallest version of
|
|
58
|
-
the same shape: one table (`example_widgets`), four routes, one
|
|
58
|
+
the same shape: one table (`example_widgets`), four routes, one idempotent
|
|
59
59
|
seed, one `@subscribe` handler.
|
|
60
60
|
|
|
61
|
+
### Seeding: not from `on_install()`
|
|
62
|
+
|
|
63
|
+
`BiffoPluginBase` requires `on_install()`, `on_uninstall()` and `on_upgrade()`,
|
|
64
|
+
and they are **not invoked** — ADR-0003 §9 described a `biffo plugin install`
|
|
65
|
+
that would call them and the call site was never built. Define them as no-ops,
|
|
66
|
+
as `plugin.py` does. An earlier revision of this skeleton seeded through
|
|
67
|
+
`on_install()`; anyone who copied it got a seed that silently never ran, the
|
|
68
|
+
plugin deploying clean with empty tables and the symptom surfacing wherever
|
|
69
|
+
those rows were read
|
|
70
|
+
([#709](https://github.com/keiranholloway/biffo-template/issues/709)).
|
|
71
|
+
|
|
72
|
+
Baseline data goes in one of two places instead, and `seed_default_widget()` in
|
|
73
|
+
`plugin.py` documents both at the call site:
|
|
74
|
+
|
|
75
|
+
- **If your plugin declares an `api_ingress` ASGI app** (ADR-0021), seed from
|
|
76
|
+
that app's lifespan. The shared plugin host runs each mounted app's lifespan
|
|
77
|
+
itself, because Starlette's `Mount` never delivers the lifespan scope — until
|
|
78
|
+
[#948](https://github.com/keiranholloway/biffo-template/pull/948) a plugin's
|
|
79
|
+
own `@app.on_event("startup")` was equally dead. It fires on every cold start,
|
|
80
|
+
so the seed must be idempotent; Core's own
|
|
81
|
+
`POST /api/v1/internal/plugins/me/config/seed` was not idempotent until
|
|
82
|
+
[#1000](https://github.com/keiranholloway/biffo-template/pull/1000), so treat
|
|
83
|
+
this route as new and check your own.
|
|
84
|
+
- **If it doesn't** — like `example_plugin`, which is event-only — there is no
|
|
85
|
+
startup to hang anything on. Put the rows in a SQL module in the instance's
|
|
86
|
+
`db/imports/<name>/`, applied by `biffo data apply` on every deploy.
|
|
87
|
+
|
|
61
88
|
> Earlier revisions of this README pointed at an RBAC reference plugin at
|
|
62
89
|
> `services/rbac/`. That plugin was removed by
|
|
63
90
|
> [ADR-0011](https://github.com/keiranholloway/biffo-template/blob/main/docs/ADR/0011-authorization-is-a-core-concern.md)
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
"name": "is_active",
|
|
25
25
|
"type": "Boolean",
|
|
26
26
|
"nullable": true,
|
|
27
|
-
"description": "Nullable because declared column defaults are not applied by the generated migration DDL (only the in-process SQLAlchemy model) — see README's 'Known limitations' section. Treat NULL as false; this plugin's own
|
|
27
|
+
"description": "Nullable because declared column defaults are not applied by the generated migration DDL (only the in-process SQLAlchemy model) — see README's 'Known limitations' section. Treat NULL as false; this plugin's own seed always sets it explicitly."
|
|
28
28
|
}
|
|
29
29
|
],
|
|
30
30
|
"indexes": [
|
|
@@ -1,11 +1,17 @@
|
|
|
1
1
|
"""ExamplePlugin — this plugin's BiffoPluginBase implementation.
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
3
|
+
Reacts to `biffo.core/UserCreated` by logging the new user, and carries an
|
|
4
|
+
idempotent seed for its baseline row. Replace both with real logic for your
|
|
5
|
+
own plugin — this exists to show a working, correctly-wired skeleton:
|
|
6
|
+
constructor -> manifest loading -> `self.api` -> `@self.subscribe`, exercised
|
|
7
|
+
end to end by the tests in `tests/test_example_plugin.py`.
|
|
8
|
+
|
|
9
|
+
**`on_install()` / `on_uninstall()` are not invoked.** They are required by
|
|
10
|
+
`BiffoPluginBase` and nothing calls them, so they are no-ops here and should
|
|
11
|
+
be no-ops in your plugin (biffo-template#709). An earlier version of this
|
|
12
|
+
skeleton seeded through `on_install()` — copying that gives you a seed that
|
|
13
|
+
silently never runs. `seed_default_widget()` below shows where seeding
|
|
14
|
+
actually goes.
|
|
9
15
|
|
|
10
16
|
Modelled directly on the RBAC reference plugin's
|
|
11
17
|
`services/rbac/src/rbac/plugin.py` (PR #76) in the biffo-template monorepo —
|
|
@@ -15,7 +21,6 @@ tables, an event-driven side effect, and an in-process (non-CRUD) helper.
|
|
|
15
21
|
|
|
16
22
|
from __future__ import annotations
|
|
17
23
|
|
|
18
|
-
import asyncio
|
|
19
24
|
from typing import Any
|
|
20
25
|
|
|
21
26
|
from aws_lambda_powertools import Logger
|
|
@@ -27,11 +32,11 @@ logger = Logger()
|
|
|
27
32
|
|
|
28
33
|
_PLUGIN_BASE_PATH = "/api/v1/plugins/example-plugin"
|
|
29
34
|
|
|
30
|
-
# Seeded by
|
|
31
|
-
# their feature needs
|
|
35
|
+
# Seeded by seed_default_widget(). Real plugins would seed whatever baseline
|
|
36
|
+
# data their feature needs.
|
|
32
37
|
_DEFAULT_WIDGET: dict[str, Any] = {
|
|
33
38
|
"name": "starter-widget",
|
|
34
|
-
"description": "Created
|
|
39
|
+
"description": "Created by this plugin's own seed. Safe to delete.",
|
|
35
40
|
"is_active": True,
|
|
36
41
|
}
|
|
37
42
|
|
|
@@ -48,23 +53,50 @@ class ExamplePlugin(BiffoPluginBase):
|
|
|
48
53
|
await self._log_user_created(event)
|
|
49
54
|
|
|
50
55
|
def on_install(self) -> None:
|
|
51
|
-
"""
|
|
56
|
+
"""No-op. **Not invoked** — nothing calls it (biffo-template#709).
|
|
52
57
|
|
|
53
|
-
`BiffoPluginBase
|
|
54
|
-
`
|
|
55
|
-
|
|
56
|
-
keep the actual async logic in the public `seed_default_widget()`,
|
|
57
|
-
which tests and any future async caller can `await` directly.
|
|
58
|
+
`BiffoPluginBase` requires it, so it is defined; `biffo plugin
|
|
59
|
+
install` does not run it, and neither does anything else. Put no
|
|
60
|
+
logic here. Seeding goes in `seed_default_widget()` below.
|
|
58
61
|
"""
|
|
59
|
-
|
|
62
|
+
return None
|
|
60
63
|
|
|
61
64
|
async def seed_default_widget(self) -> None:
|
|
62
|
-
"""
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
65
|
+
"""Create this plugin's baseline row, idempotently.
|
|
66
|
+
|
|
67
|
+
Nothing in this skeleton calls this — deliberately. **Where you call
|
|
68
|
+
it from depends on what kind of plugin you are building**, and
|
|
69
|
+
neither answer is `on_install()`:
|
|
70
|
+
|
|
71
|
+
- **A plugin with an `api_ingress` ASGI app** (ADR-0021) seeds from
|
|
72
|
+
its app's *lifespan*, which the shared plugin host drives on the
|
|
73
|
+
first invocation of each process::
|
|
74
|
+
|
|
75
|
+
@asynccontextmanager
|
|
76
|
+
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
|
77
|
+
await plugin.seed_default_widget()
|
|
78
|
+
yield
|
|
79
|
+
|
|
80
|
+
app = FastAPI(lifespan=lifespan)
|
|
81
|
+
|
|
82
|
+
The host has to run that handshake itself, because Starlette's
|
|
83
|
+
`Mount` never delivers the lifespan scope to a mounted app — until
|
|
84
|
+
biffo-template#948 a plugin's own `@app.on_event("startup")` was as
|
|
85
|
+
dead as `on_install()`. It runs on every cold start, so the seed
|
|
86
|
+
must be idempotent (below), and Core's own seed endpoint
|
|
87
|
+
`POST /api/v1/internal/plugins/me/config/seed` was itself not
|
|
88
|
+
idempotent until #1000. Verify your seed; do not assume it.
|
|
89
|
+
|
|
90
|
+
- **An event-only plugin like this one** declares no `api_ingress`,
|
|
91
|
+
so it has no startup at all. Its baseline rows belong in a SQL
|
|
92
|
+
module in the *instance's* `db/imports/<name>/`, applied by
|
|
93
|
+
`biffo data apply` on every deploy — the mechanism the first-party
|
|
94
|
+
plugins use.
|
|
95
|
+
|
|
96
|
+
Idempotency: generic CRUD (issue #19) has no upsert — every POST
|
|
97
|
+
creates a new row — so this lists existing widgets first and only
|
|
98
|
+
creates the default one if it's missing by name, making a repeated
|
|
99
|
+
cold start a no-op instead of accumulating duplicates.
|
|
68
100
|
"""
|
|
69
101
|
existing = await self.api.get(f"{_PLUGIN_BASE_PATH}/widgets")
|
|
70
102
|
existing_names = {row.get("name") for row in existing}
|
|
@@ -72,13 +104,12 @@ class ExamplePlugin(BiffoPluginBase):
|
|
|
72
104
|
await self.api.post(f"{_PLUGIN_BASE_PATH}/widgets", json=dict(_DEFAULT_WIDGET))
|
|
73
105
|
|
|
74
106
|
def on_uninstall(self) -> None:
|
|
75
|
-
"""
|
|
107
|
+
"""No-op. **Not invoked** — nothing calls it (biffo-template#709).
|
|
76
108
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
own tables).
|
|
109
|
+
There is no teardown moment for it to belong to either: `biffo plugin
|
|
110
|
+
uninstall` removes the plugin's code and Terraform and deliberately
|
|
111
|
+
leaves its tables in place (ADR-0003 section 9). Cleanup beyond that
|
|
112
|
+
is a hand-written Alembic migration, not a hook.
|
|
82
113
|
"""
|
|
83
114
|
return None
|
|
84
115
|
|
|
@@ -1,5 +1,9 @@
|
|
|
1
|
-
"""Tests for ExamplePlugin: manifest loading,
|
|
2
|
-
|
|
1
|
+
"""Tests for ExamplePlugin: manifest loading, the seed, and the UserCreated
|
|
2
|
+
event subscription.
|
|
3
|
+
|
|
4
|
+
`on_install()` / `on_uninstall()` are asserted to be no-ops, not to seed:
|
|
5
|
+
they are **not invoked** by anything (biffo-template#709), so a test that
|
|
6
|
+
proved seeding through them would be proving something that never happens.
|
|
3
7
|
|
|
4
8
|
Modelled on the RBAC reference plugin's
|
|
5
9
|
`services/rbac/tests/test_rbac_plugin.py` (PR #76) in the biffo-template
|
|
@@ -48,7 +52,7 @@ class TestSubscription:
|
|
|
48
52
|
assert plugin.events.has_subscription("UserCreated")
|
|
49
53
|
|
|
50
54
|
|
|
51
|
-
class
|
|
55
|
+
class TestSeeding:
|
|
52
56
|
async def test_seeds_default_widget(self) -> None:
|
|
53
57
|
plugin, fake = _make_plugin()
|
|
54
58
|
|
|
@@ -73,22 +77,29 @@ class TestOnInstall:
|
|
|
73
77
|
widget = fake.tables["widgets"][0]
|
|
74
78
|
assert widget["is_active"] is True
|
|
75
79
|
|
|
80
|
+
|
|
81
|
+
class TestLifecycleHooksAreNoOps:
|
|
82
|
+
"""The ABC's hooks are **not invoked** by anything (biffo-template#709).
|
|
83
|
+
|
|
84
|
+
So the contract worth asserting is that they do nothing — in particular
|
|
85
|
+
that neither of them seeds. A plugin whose baseline data depends on one
|
|
86
|
+
of these ships a seed that never runs.
|
|
87
|
+
"""
|
|
88
|
+
|
|
89
|
+
def test_on_install_is_a_noop_and_seeds_nothing(self) -> None:
|
|
90
|
+
plugin, fake = _make_plugin()
|
|
91
|
+
|
|
92
|
+
assert plugin.on_install() is None
|
|
93
|
+
assert fake.tables["widgets"] == []
|
|
94
|
+
|
|
76
95
|
def test_on_uninstall_is_a_noop(self) -> None:
|
|
77
96
|
plugin, _ = _make_plugin()
|
|
78
97
|
assert plugin.on_uninstall() is None
|
|
79
98
|
|
|
80
|
-
def
|
|
81
|
-
"""The public, ABC-compliant on_install() entrypoint (a plain sync
|
|
82
|
-
function calling asyncio.run(seed_default_widget()) internally) —
|
|
83
|
-
exercised from a genuinely synchronous test function, the same kind
|
|
84
|
-
of context the CLI would call it from, since asyncio.run() cannot be
|
|
85
|
-
nested inside pytest-asyncio's already-running loop."""
|
|
99
|
+
def test_on_upgrade_is_a_noop(self) -> None:
|
|
86
100
|
plugin, fake = _make_plugin()
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
names = {r["name"] for r in fake.tables["widgets"]}
|
|
91
|
-
assert names == {_DEFAULT_WIDGET["name"]}
|
|
101
|
+
assert plugin.on_upgrade("0.0.1") is None
|
|
102
|
+
assert fake.tables["widgets"] == []
|
|
92
103
|
|
|
93
104
|
|
|
94
105
|
class TestUserCreatedLogsEvent:
|
package/dist/index.js
CHANGED
|
@@ -216,6 +216,34 @@ function latestCoreVersionFromTags(repo, git = defaultTagRunner, options = {}) {
|
|
|
216
216
|
var CORE_TAG_PREFIX = "core-v";
|
|
217
217
|
var defaultTagRunner = (args) => execFileSync("git", args, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
|
|
218
218
|
|
|
219
|
+
// src/lib/git-tracked-files.ts
|
|
220
|
+
import { execFileSync as execFileSync2 } from "child_process";
|
|
221
|
+
import { realpathSync } from "fs";
|
|
222
|
+
var defaultGit = (args) => execFileSync2("git", args, { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
|
|
223
|
+
function gitTrackedFiles(root, git = defaultGit) {
|
|
224
|
+
let top;
|
|
225
|
+
try {
|
|
226
|
+
top = git(["-C", root, "rev-parse", "--show-toplevel"]).trim();
|
|
227
|
+
} catch {
|
|
228
|
+
return null;
|
|
229
|
+
}
|
|
230
|
+
if (!top) return null;
|
|
231
|
+
try {
|
|
232
|
+
if (realpathSync(top) !== realpathSync(root)) return null;
|
|
233
|
+
} catch {
|
|
234
|
+
return null;
|
|
235
|
+
}
|
|
236
|
+
let out;
|
|
237
|
+
try {
|
|
238
|
+
out = git(["-C", root, "ls-files", "-z"]);
|
|
239
|
+
} catch {
|
|
240
|
+
return null;
|
|
241
|
+
}
|
|
242
|
+
const files = out.split("\0").filter((p) => p !== "");
|
|
243
|
+
if (files.length === 0) return null;
|
|
244
|
+
return new Set(files);
|
|
245
|
+
}
|
|
246
|
+
|
|
219
247
|
// src/lib/core-manifest.ts
|
|
220
248
|
var CORE_MANIFEST_FILE = "core-manifest.json";
|
|
221
249
|
var CoreManifestSchema2 = z2.object({
|
|
@@ -315,8 +343,9 @@ function isTemplateOwned(relPath, manifest) {
|
|
|
315
343
|
function toPosix(p) {
|
|
316
344
|
return sep === "/" ? p : p.split(sep).join("/");
|
|
317
345
|
}
|
|
318
|
-
function listTemplateOwnedFiles(root, manifest) {
|
|
346
|
+
function listTemplateOwnedFiles(root, manifest, options = {}) {
|
|
319
347
|
const out = [];
|
|
348
|
+
const tracked = options.trackedOnly ? options.git ? gitTrackedFiles(root, options.git) : gitTrackedFiles(root) : null;
|
|
320
349
|
function walk(dir) {
|
|
321
350
|
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
322
351
|
if (entry.isDirectory() && HARD_EXCLUDED_DIRS.has(entry.name)) continue;
|
|
@@ -325,6 +354,7 @@ function listTemplateOwnedFiles(root, manifest) {
|
|
|
325
354
|
walk(abs);
|
|
326
355
|
} else if (entry.isFile()) {
|
|
327
356
|
const rel = toPosix(relative(root, abs));
|
|
357
|
+
if (tracked && !tracked.has(rel)) continue;
|
|
328
358
|
if (isTemplateOwned(rel, manifest)) out.push(rel);
|
|
329
359
|
}
|
|
330
360
|
}
|
|
@@ -335,10 +365,11 @@ function listTemplateOwnedFiles(root, manifest) {
|
|
|
335
365
|
function sameFile(a, b) {
|
|
336
366
|
return readFileSync2(a).equals(readFileSync2(b));
|
|
337
367
|
}
|
|
338
|
-
function computeCoreDiff(templateRoot, instanceRoot, manifest, baseRoot) {
|
|
339
|
-
const
|
|
368
|
+
function computeCoreDiff(templateRoot, instanceRoot, manifest, baseRoot, options = {}) {
|
|
369
|
+
const trackedOnly = options.git ? { trackedOnly: true, git: options.git } : { trackedOnly: true };
|
|
370
|
+
const templateFiles = new Set(listTemplateOwnedFiles(templateRoot, manifest, trackedOnly));
|
|
340
371
|
const instanceFiles = new Set(listTemplateOwnedFiles(instanceRoot, manifest));
|
|
341
|
-
const baseFiles = baseRoot ? new Set(listTemplateOwnedFiles(baseRoot, manifest)) : void 0;
|
|
372
|
+
const baseFiles = baseRoot ? new Set(listTemplateOwnedFiles(baseRoot, manifest, trackedOnly)) : void 0;
|
|
342
373
|
const diff = {
|
|
343
374
|
added: [],
|
|
344
375
|
removed: [],
|
|
@@ -687,9 +718,10 @@ var EMPTY_SUMMARY = () => ({
|
|
|
687
718
|
});
|
|
688
719
|
async function planCoreUpgrade(options) {
|
|
689
720
|
const mergeFile = options.mergeFile ?? gitMergeFile;
|
|
690
|
-
const
|
|
721
|
+
const trackedOnly = { trackedOnly: true };
|
|
722
|
+
const base = new Set(listTemplateOwnedFiles(options.baseDir, options.manifest, trackedOnly));
|
|
691
723
|
const ours = new Set(listTemplateOwnedFiles(options.oursDir, options.manifest));
|
|
692
|
-
const theirs = new Set(listTemplateOwnedFiles(options.theirsDir, options.manifest));
|
|
724
|
+
const theirs = new Set(listTemplateOwnedFiles(options.theirsDir, options.manifest, trackedOnly));
|
|
693
725
|
const divergentPrefixes = readDivergenceConfig(options.oursDir).warnOnly.map((e) => e.prefix);
|
|
694
726
|
const isDeclaredDivergent = (path) => divergentPrefixes.some((prefix) => path.startsWith(prefix));
|
|
695
727
|
const paths = [.../* @__PURE__ */ new Set([...base, ...ours, ...theirs])].sort();
|
|
@@ -2401,7 +2433,7 @@ function applyMigrationCarry(instanceDir, plan) {
|
|
|
2401
2433
|
}
|
|
2402
2434
|
|
|
2403
2435
|
// src/lib/core-template-trees.ts
|
|
2404
|
-
import { execFileSync as
|
|
2436
|
+
import { execFileSync as execFileSync3 } from "child_process";
|
|
2405
2437
|
import { mkdtempSync as mkdtempSync3, rmSync as rmSync3 } from "fs";
|
|
2406
2438
|
import { tmpdir as tmpdir3 } from "os";
|
|
2407
2439
|
import { join as join8 } from "path";
|
|
@@ -2409,8 +2441,8 @@ function coreTag(version) {
|
|
|
2409
2441
|
parseCoreVersion(version);
|
|
2410
2442
|
return `core-v${version}`;
|
|
2411
2443
|
}
|
|
2412
|
-
var
|
|
2413
|
-
function workingTreeMatchesTag(repo, version, git =
|
|
2444
|
+
var defaultGit2 = (args) => execFileSync3("git", args, { encoding: "utf8" });
|
|
2445
|
+
function workingTreeMatchesTag(repo, version, git = defaultGit2) {
|
|
2414
2446
|
const tag = coreTag(version);
|
|
2415
2447
|
try {
|
|
2416
2448
|
const head = git(["-C", repo, "rev-parse", "HEAD"]).trim();
|
|
@@ -2430,7 +2462,7 @@ function tagExists(repo, tag, git) {
|
|
|
2430
2462
|
return false;
|
|
2431
2463
|
}
|
|
2432
2464
|
}
|
|
2433
|
-
function materializeTemplateAtTag(repo, version, git =
|
|
2465
|
+
function materializeTemplateAtTag(repo, version, git = defaultGit2) {
|
|
2434
2466
|
const tag = coreTag(version);
|
|
2435
2467
|
if (!tagExists(repo, tag, git)) {
|
|
2436
2468
|
try {
|
|
@@ -2447,7 +2479,7 @@ function materializeTemplateAtTag(repo, version, git = defaultGit) {
|
|
|
2447
2479
|
const tarball = join8(dir, ".tree.tar");
|
|
2448
2480
|
try {
|
|
2449
2481
|
git(["-C", repo, "archive", "--format=tar", "-o", tarball, tag]);
|
|
2450
|
-
|
|
2482
|
+
execFileSync3("tar", ["-x", "-f", tarball, "-C", dir]);
|
|
2451
2483
|
rmSync3(tarball, { force: true });
|
|
2452
2484
|
} catch (err) {
|
|
2453
2485
|
rmSync3(dir, { recursive: true, force: true });
|