@biffo/cli 0.38.0 → 0.41.0
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/.github/workflows/ci.yml +124 -0
- package/_skeletons/plugin-template/.github/workflows/release.yml +114 -0
- package/_skeletons/plugin-template/README.md +232 -0
- package/_skeletons/plugin-template/biffo.plugin.json +89 -0
- package/_skeletons/plugin-template/pyproject.toml +100 -0
- package/_skeletons/plugin-template/registry-schema.json +294 -0
- package/_skeletons/plugin-template/src/__init__.py +0 -0
- package/_skeletons/plugin-template/src/example_plugin/__init__.py +10 -0
- package/_skeletons/plugin-template/src/example_plugin/main.py +63 -0
- package/_skeletons/plugin-template/src/example_plugin/manifest.py +30 -0
- package/_skeletons/plugin-template/src/example_plugin/plugin.py +92 -0
- package/_skeletons/plugin-template/terraform/README.md +146 -0
- package/_skeletons/plugin-template/terraform/main.tf +167 -0
- package/_skeletons/plugin-template/terraform/outputs.tf +33 -0
- package/_skeletons/plugin-template/terraform/variables.tf +133 -0
- package/_skeletons/plugin-template/tests/conftest.py +17 -0
- package/_skeletons/plugin-template/tests/fakes.py +74 -0
- package/_skeletons/plugin-template/tests/test_example_plugin.py +108 -0
- package/_skeletons/registry/README.md +27 -0
- package/_skeletons/registry/plugins.json +5 -0
- package/_skeletons/registry/registry-schema.json +294 -0
- package/_skeletons/sibling-template/.github/renovate.json +38 -0
- package/_skeletons/sibling-template/.github/workflows/ci.yml +185 -0
- package/_skeletons/sibling-template/.github/workflows/codeql.yml +56 -0
- package/_skeletons/sibling-template/.github/workflows/deploy.yml +219 -0
- package/_skeletons/sibling-template/.github/workflows/destroy-infra.yml +73 -0
- package/_skeletons/sibling-template/README.md +164 -0
- package/_skeletons/sibling-template/_gitignore +71 -0
- package/_skeletons/sibling-template/apps/frontend/.env.example +14 -0
- package/_skeletons/sibling-template/apps/frontend/eslint.config.mjs +10 -0
- package/_skeletons/sibling-template/apps/frontend/next.config.ts +20 -0
- package/_skeletons/sibling-template/apps/frontend/package.json +42 -0
- package/_skeletons/sibling-template/apps/frontend/pnpm-lock.yaml +5268 -0
- package/_skeletons/sibling-template/apps/frontend/pnpm-workspace.yaml +19 -0
- package/_skeletons/sibling-template/apps/frontend/src/app/globals.css +34 -0
- package/_skeletons/sibling-template/apps/frontend/src/app/layout.tsx +15 -0
- package/_skeletons/sibling-template/apps/frontend/src/app/page.test.tsx +75 -0
- package/_skeletons/sibling-template/apps/frontend/src/app/page.tsx +85 -0
- package/_skeletons/sibling-template/apps/frontend/src/lib/api-client.ts +58 -0
- package/_skeletons/sibling-template/apps/frontend/src/lib/auth.test.ts +135 -0
- package/_skeletons/sibling-template/apps/frontend/src/lib/auth.ts +87 -0
- package/_skeletons/sibling-template/apps/frontend/src/test-setup.ts +1 -0
- package/_skeletons/sibling-template/apps/frontend/tsconfig.json +29 -0
- package/_skeletons/sibling-template/apps/frontend/vitest.config.ts +18 -0
- package/_skeletons/sibling-template/biffo.sibling.json +7 -0
- package/_skeletons/sibling-template/infra/backend.tf +23 -0
- package/_skeletons/sibling-template/infra/main.tf +79 -0
- package/_skeletons/sibling-template/infra/outputs.tf +17 -0
- package/_skeletons/sibling-template/infra/variables.tf +63 -0
- package/_skeletons/sibling-template/modules/cloud/aws/api-gateway/main.tf +114 -0
- package/_skeletons/sibling-template/modules/cloud/aws/api-gateway/outputs.tf +12 -0
- package/_skeletons/sibling-template/modules/cloud/aws/api-gateway/variables.tf +41 -0
- package/_skeletons/sibling-template/modules/cloud/aws/compute/main.tf +157 -0
- package/_skeletons/sibling-template/modules/cloud/aws/compute/outputs.tf +4 -0
- package/_skeletons/sibling-template/modules/cloud/aws/compute/variables.tf +70 -0
- package/_skeletons/sibling-template/modules/cloud/aws/storage/main.tf +78 -0
- package/_skeletons/sibling-template/modules/cloud/aws/storage/outputs.tf +4 -0
- package/_skeletons/sibling-template/modules/cloud/aws/storage/variables.tf +12 -0
- package/_skeletons/sibling-template/services/api/pyproject.toml +69 -0
- package/_skeletons/sibling-template/services/api/src/api/__init__.py +0 -0
- package/_skeletons/sibling-template/services/api/src/api/config.py +31 -0
- package/_skeletons/sibling-template/services/api/src/api/core_client.py +64 -0
- package/_skeletons/sibling-template/services/api/src/api/main.py +43 -0
- package/_skeletons/sibling-template/services/api/src/api/middleware/__init__.py +0 -0
- package/_skeletons/sibling-template/services/api/src/api/middleware/auth.py +111 -0
- package/_skeletons/sibling-template/services/api/src/api/routers/__init__.py +0 -0
- package/_skeletons/sibling-template/services/api/src/api/routers/whoami.py +21 -0
- package/_skeletons/sibling-template/services/api/tests/conftest.py +24 -0
- package/_skeletons/sibling-template/services/api/tests/test_whoami.py +21 -0
- package/_skeletons/sibling-template/services/api/uv.lock +1160 -0
- package/core.version +1 -1
- package/dist/index.js +113 -87
- package/package.json +5 -4
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "http://json-schema.org/draft-07/schema#",
|
|
3
|
+
"title": "Biffo Plugin Manifest",
|
|
4
|
+
"description": "Schema for biffo.plugin.json — declares what a plugin provides and needs.",
|
|
5
|
+
"type": "object",
|
|
6
|
+
"required": ["name", "version", "tables", "api_routes", "ui_components"],
|
|
7
|
+
"properties": {
|
|
8
|
+
"name": {
|
|
9
|
+
"type": "string",
|
|
10
|
+
"pattern": "^[a-z][a-z0-9-]*$",
|
|
11
|
+
"description": "Unique plugin slug (lowercase, kebab-case)."
|
|
12
|
+
},
|
|
13
|
+
"version": {
|
|
14
|
+
"type": "string",
|
|
15
|
+
"pattern": "^\\d+\\.\\d+\\.\\d+$",
|
|
16
|
+
"description": "Semver version of this plugin release."
|
|
17
|
+
},
|
|
18
|
+
"description": {
|
|
19
|
+
"type": "string",
|
|
20
|
+
"maxLength": 500,
|
|
21
|
+
"description": "Human-readable summary shown in the marketplace."
|
|
22
|
+
},
|
|
23
|
+
"author": {
|
|
24
|
+
"type": "string",
|
|
25
|
+
"default": "Biffo Team",
|
|
26
|
+
"description": "Publisher name."
|
|
27
|
+
},
|
|
28
|
+
"tags": {
|
|
29
|
+
"type": "array",
|
|
30
|
+
"items": { "type": "string" },
|
|
31
|
+
"description": "Categorisation tags for filtering/search."
|
|
32
|
+
},
|
|
33
|
+
"required_core_version": {
|
|
34
|
+
"type": "string",
|
|
35
|
+
"default": ">=0.0.0",
|
|
36
|
+
"description": "Minimum Biffo core version (npm-style range)."
|
|
37
|
+
},
|
|
38
|
+
"tables": {
|
|
39
|
+
"type": "array",
|
|
40
|
+
"items": {
|
|
41
|
+
"type": "object",
|
|
42
|
+
"required": ["name"],
|
|
43
|
+
"properties": {
|
|
44
|
+
"name": {
|
|
45
|
+
"type": "string",
|
|
46
|
+
"pattern": "^[a-z][a-z0-9_]*$",
|
|
47
|
+
"description": "PostgreSQL table name (snake_case)."
|
|
48
|
+
},
|
|
49
|
+
"columns": {
|
|
50
|
+
"type": "array",
|
|
51
|
+
"description": "Additional columns beyond id/tenant_id/created_at/updated_at, which are auto-injected on every table per ADR-0001 and must NOT be declared here — doing so fails validation (ColumnDefinition/PluginTableDefinition in services/api/src/api/models/plugin_table.py).",
|
|
52
|
+
"items": {
|
|
53
|
+
"type": "object",
|
|
54
|
+
"required": ["name", "type"],
|
|
55
|
+
"properties": {
|
|
56
|
+
"name": {
|
|
57
|
+
"type": "string",
|
|
58
|
+
"not": { "enum": ["id", "tenant_id", "created_at", "updated_at"] },
|
|
59
|
+
"description": "Column name. The names id, tenant_id, created_at, and updated_at are reserved (auto-injected) and rejected here."
|
|
60
|
+
},
|
|
61
|
+
"type": {
|
|
62
|
+
"type": "string",
|
|
63
|
+
"pattern": "^(String|Integer|Text|Boolean|Float|DateTime)(\\(.*\\))?$",
|
|
64
|
+
"description": "SQLAlchemy column type constructor string, e.g. 'String(255)', 'Integer', or 'DateTime(timezone=True)'. Must be one of the base types plugin_table.py's _TYPE_MAP resolves (String, Integer, Text, Boolean, Float, DateTime) — anything else silently falls back to String, so this schema rejects it up front rather than allowing a PostgreSQL-style type name (e.g. UUID, TIMESTAMP) that looks valid but isn't."
|
|
65
|
+
},
|
|
66
|
+
"primary_key": {
|
|
67
|
+
"type": "boolean",
|
|
68
|
+
"default": false,
|
|
69
|
+
"description": "Whether this is the primary key."
|
|
70
|
+
},
|
|
71
|
+
"nullable": {
|
|
72
|
+
"type": "boolean",
|
|
73
|
+
"default": false,
|
|
74
|
+
"description": "Whether NULL values are allowed. Defaults to false (NOT NULL)."
|
|
75
|
+
},
|
|
76
|
+
"index": {
|
|
77
|
+
"type": "boolean",
|
|
78
|
+
"default": false,
|
|
79
|
+
"description": "Create a database index on this column."
|
|
80
|
+
},
|
|
81
|
+
"default": { "type": "string", "description": "SQL default value expression." },
|
|
82
|
+
"description": {
|
|
83
|
+
"type": "string",
|
|
84
|
+
"default": "",
|
|
85
|
+
"description": "Human-readable column description."
|
|
86
|
+
}
|
|
87
|
+
},
|
|
88
|
+
"additionalProperties": false
|
|
89
|
+
}
|
|
90
|
+
},
|
|
91
|
+
"indexes": {
|
|
92
|
+
"type": "array",
|
|
93
|
+
"description": "Explicit (optionally multi-column or unique) indexes on the table, beyond any single-column indexes already requested via a column's index: true.",
|
|
94
|
+
"items": {
|
|
95
|
+
"type": "object",
|
|
96
|
+
"required": ["name", "columns"],
|
|
97
|
+
"properties": {
|
|
98
|
+
"name": { "type": "string", "description": "Index name in the database." },
|
|
99
|
+
"columns": {
|
|
100
|
+
"type": "array",
|
|
101
|
+
"items": { "type": "string" },
|
|
102
|
+
"minItems": 1,
|
|
103
|
+
"description": "Column names included in the index."
|
|
104
|
+
},
|
|
105
|
+
"unique": {
|
|
106
|
+
"type": "boolean",
|
|
107
|
+
"default": false,
|
|
108
|
+
"description": "Whether the index enforces uniqueness."
|
|
109
|
+
}
|
|
110
|
+
},
|
|
111
|
+
"additionalProperties": false
|
|
112
|
+
}
|
|
113
|
+
},
|
|
114
|
+
"permissions": {
|
|
115
|
+
"type": "object",
|
|
116
|
+
"additionalProperties": false,
|
|
117
|
+
"description": "Declarative per-operation generic-CRUD permissions (ADR-0004; TablePermissions in services/api/src/api/models/plugin_table.py). Default-deny: an absent permissions block, an absent operation key, or an operation with allowed:false ALL make the table invisible to the generic CRUD layer for that operation. required_role is an any-of allow-list matched against the caller's roles (cognito:groups); an empty list means any authenticated caller. Tenant scoping (ADR-0001) is always applied and is deliberately not configurable here. Only the five operation keys below are permitted.",
|
|
118
|
+
"properties": {
|
|
119
|
+
"list": {
|
|
120
|
+
"type": "object",
|
|
121
|
+
"additionalProperties": false,
|
|
122
|
+
"properties": {
|
|
123
|
+
"allowed": {
|
|
124
|
+
"type": "boolean",
|
|
125
|
+
"default": false,
|
|
126
|
+
"description": "Whether the generic CRUD layer exposes this operation at all."
|
|
127
|
+
},
|
|
128
|
+
"required_role": {
|
|
129
|
+
"type": "array",
|
|
130
|
+
"items": { "type": "string" },
|
|
131
|
+
"default": [],
|
|
132
|
+
"description": "Any-of role allow-list; empty means any authenticated caller."
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
},
|
|
136
|
+
"read": {
|
|
137
|
+
"type": "object",
|
|
138
|
+
"additionalProperties": false,
|
|
139
|
+
"properties": {
|
|
140
|
+
"allowed": {
|
|
141
|
+
"type": "boolean",
|
|
142
|
+
"default": false,
|
|
143
|
+
"description": "Whether the generic CRUD layer exposes this operation at all."
|
|
144
|
+
},
|
|
145
|
+
"required_role": {
|
|
146
|
+
"type": "array",
|
|
147
|
+
"items": { "type": "string" },
|
|
148
|
+
"default": [],
|
|
149
|
+
"description": "Any-of role allow-list; empty means any authenticated caller."
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
},
|
|
153
|
+
"create": {
|
|
154
|
+
"type": "object",
|
|
155
|
+
"additionalProperties": false,
|
|
156
|
+
"properties": {
|
|
157
|
+
"allowed": {
|
|
158
|
+
"type": "boolean",
|
|
159
|
+
"default": false,
|
|
160
|
+
"description": "Whether the generic CRUD layer exposes this operation at all."
|
|
161
|
+
},
|
|
162
|
+
"required_role": {
|
|
163
|
+
"type": "array",
|
|
164
|
+
"items": { "type": "string" },
|
|
165
|
+
"default": [],
|
|
166
|
+
"description": "Any-of role allow-list; empty means any authenticated caller."
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
},
|
|
170
|
+
"update": {
|
|
171
|
+
"type": "object",
|
|
172
|
+
"additionalProperties": false,
|
|
173
|
+
"properties": {
|
|
174
|
+
"allowed": {
|
|
175
|
+
"type": "boolean",
|
|
176
|
+
"default": false,
|
|
177
|
+
"description": "Whether the generic CRUD layer exposes this operation at all."
|
|
178
|
+
},
|
|
179
|
+
"required_role": {
|
|
180
|
+
"type": "array",
|
|
181
|
+
"items": { "type": "string" },
|
|
182
|
+
"default": [],
|
|
183
|
+
"description": "Any-of role allow-list; empty means any authenticated caller."
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
},
|
|
187
|
+
"delete": {
|
|
188
|
+
"type": "object",
|
|
189
|
+
"additionalProperties": false,
|
|
190
|
+
"properties": {
|
|
191
|
+
"allowed": {
|
|
192
|
+
"type": "boolean",
|
|
193
|
+
"default": false,
|
|
194
|
+
"description": "Whether the generic CRUD layer exposes this operation at all."
|
|
195
|
+
},
|
|
196
|
+
"required_role": {
|
|
197
|
+
"type": "array",
|
|
198
|
+
"items": { "type": "string" },
|
|
199
|
+
"default": [],
|
|
200
|
+
"description": "Any-of role allow-list; empty means any authenticated caller."
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
},
|
|
207
|
+
"additionalProperties": false
|
|
208
|
+
},
|
|
209
|
+
"description": "Database tables the plugin creates via Alembic migrations."
|
|
210
|
+
},
|
|
211
|
+
"api_routes": {
|
|
212
|
+
"type": "array",
|
|
213
|
+
"items": {
|
|
214
|
+
"type": "object",
|
|
215
|
+
"required": ["method", "path", "table", "operation"],
|
|
216
|
+
"properties": {
|
|
217
|
+
"method": {
|
|
218
|
+
"enum": ["GET", "POST", "PUT", "PATCH", "DELETE"],
|
|
219
|
+
"description": "HTTP method. Must be compatible with `operation` (list/read=GET, create=POST, update=PUT/PATCH, delete=DELETE) — see plugin_route.py's _OPERATION_METHODS."
|
|
220
|
+
},
|
|
221
|
+
"path": {
|
|
222
|
+
"type": "string",
|
|
223
|
+
"pattern": "^/[a-zA-Z0-9/_{}-]+$",
|
|
224
|
+
"description": "Path relative to the plugin's mount point (/api/v1/plugins/<name>), e.g. '/widgets' or '/widgets/{id}'. Must start with '/'. Single-row operations (read/update/delete) address a row via an '{id}' path parameter; collection operations (list/create) must not."
|
|
225
|
+
},
|
|
226
|
+
"table": {
|
|
227
|
+
"type": "string",
|
|
228
|
+
"description": "Name of a table declared in this manifest's `tables` that this route exposes. A route cannot reference another plugin's table (RouteDefinition / parse_plugin_routes_from_manifest in services/api/src/api/models/plugin_route.py)."
|
|
229
|
+
},
|
|
230
|
+
"operation": {
|
|
231
|
+
"enum": ["list", "read", "create", "update", "delete"],
|
|
232
|
+
"description": "Generic CRUD operation the Core API synthesizes for this route against `table`, scoped to the caller's tenant_id (ADR-0001). A plugin manifest cannot ship executable handler code the Core API imports and runs (ADR-0002); routes are declarative (issue #19)."
|
|
233
|
+
},
|
|
234
|
+
"description": { "type": "string" }
|
|
235
|
+
}
|
|
236
|
+
},
|
|
237
|
+
"description": "Declarative generic-CRUD routes the Core API synthesizes against this plugin's own tables (issue #19 / ADR-0002), mounted under /api/v1/plugins/<name>."
|
|
238
|
+
},
|
|
239
|
+
"event_subscriptions": {
|
|
240
|
+
"type": "array",
|
|
241
|
+
"items": {
|
|
242
|
+
"type": "object",
|
|
243
|
+
"required": ["detail_type"],
|
|
244
|
+
"properties": {
|
|
245
|
+
"source": { "type": "string", "default": "biffo.core" },
|
|
246
|
+
"detail_type": { "type": "string" },
|
|
247
|
+
"handler": { "type": "string" }
|
|
248
|
+
}
|
|
249
|
+
},
|
|
250
|
+
"description": "EventBridge events the plugin subscribes to."
|
|
251
|
+
},
|
|
252
|
+
"infra_modules": {
|
|
253
|
+
"type": "array",
|
|
254
|
+
"items": {
|
|
255
|
+
"enum": [
|
|
256
|
+
"compute",
|
|
257
|
+
"storage",
|
|
258
|
+
"events",
|
|
259
|
+
"cdn",
|
|
260
|
+
"database",
|
|
261
|
+
"auth",
|
|
262
|
+
"networking",
|
|
263
|
+
"oidc",
|
|
264
|
+
"api-gateway"
|
|
265
|
+
]
|
|
266
|
+
},
|
|
267
|
+
"description": "Terraform module categories the plugin provisions."
|
|
268
|
+
},
|
|
269
|
+
"ui_components": {
|
|
270
|
+
"type": "array",
|
|
271
|
+
"items": {
|
|
272
|
+
"type": "object",
|
|
273
|
+
"required": ["type", "label", "path"],
|
|
274
|
+
"properties": {
|
|
275
|
+
"type": {
|
|
276
|
+
"enum": ["nav-link", "page", "dashboard-widget", "modal", "dialog"],
|
|
277
|
+
"description": "UI component type."
|
|
278
|
+
},
|
|
279
|
+
"label": { "type": "string" },
|
|
280
|
+
"path": { "type": "string" },
|
|
281
|
+
"icon": { "type": "string" },
|
|
282
|
+
"requires_auth": { "type": "boolean", "default": true }
|
|
283
|
+
}
|
|
284
|
+
},
|
|
285
|
+
"description": "Portal UI elements the plugin adds."
|
|
286
|
+
},
|
|
287
|
+
"dependencies": {
|
|
288
|
+
"type": "object",
|
|
289
|
+
"additionalProperties": { "type": "string" },
|
|
290
|
+
"description": "Python package dependencies (including biffo-plugin-sdk)."
|
|
291
|
+
}
|
|
292
|
+
},
|
|
293
|
+
"additionalProperties": false
|
|
294
|
+
}
|
|
File without changes
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""Example plugin — reference implementation shipped with the plugin
|
|
2
|
+
repository template.
|
|
3
|
+
|
|
4
|
+
Rename this package (and `example_plugin` throughout the repo) to your
|
|
5
|
+
plugin's own name when you start from this template. See README.md.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from .plugin import ExamplePlugin
|
|
9
|
+
|
|
10
|
+
__all__ = ["ExamplePlugin"]
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Example plugin Lambda entrypoint.
|
|
2
|
+
|
|
3
|
+
Mirrors the RBAC reference plugin's `services/rbac/src/rbac/main.py` and
|
|
4
|
+
`services/_template/src/service/main.py`'s documented event shape and rules
|
|
5
|
+
(ADR-0002): never import a DB client, talk to the Core API only via
|
|
6
|
+
`BiffoAPIClient`, react to state changes via EventBridge.
|
|
7
|
+
|
|
8
|
+
This handler's only responsibility is dispatching EventBridge events the
|
|
9
|
+
plugin subscribes to (see `plugin.py`) through `ExamplePlugin`'s
|
|
10
|
+
`EventSubscriber`. It is not wired to a real Lambda in this template — that
|
|
11
|
+
wiring (an EventBridge rule targeting this function) belongs to your
|
|
12
|
+
plugin's own `terraform/` module, added once ADR-0003's conditional plugin
|
|
13
|
+
Terraform inclusion (issue #25) lands in a generated project.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import asyncio
|
|
19
|
+
|
|
20
|
+
from aws_lambda_powertools import Logger, Tracer
|
|
21
|
+
from aws_lambda_powertools.utilities.typing import LambdaContext
|
|
22
|
+
from biffo_plugin_sdk import create_event_handler
|
|
23
|
+
|
|
24
|
+
from .plugin import ExamplePlugin
|
|
25
|
+
|
|
26
|
+
logger = Logger()
|
|
27
|
+
tracer = Tracer()
|
|
28
|
+
|
|
29
|
+
# Reuse one event loop across warm invocations. asyncio.run() closes the loop
|
|
30
|
+
# each call, but the plugin's reused httpx client pools connections bound to
|
|
31
|
+
# the first loop -> "RuntimeError: Event loop is closed" on the next
|
|
32
|
+
# invocation. Mirrors services/api/src/api/main.py.
|
|
33
|
+
_loop = asyncio.new_event_loop()
|
|
34
|
+
asyncio.set_event_loop(_loop)
|
|
35
|
+
|
|
36
|
+
_plugin: ExamplePlugin | None = None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _get_plugin() -> ExamplePlugin:
|
|
40
|
+
"""Lazily construct the plugin singleton, reused across warm invocations
|
|
41
|
+
— a misconfigured environment (e.g. missing BIFFO_CORE_API_URL) surfaces
|
|
42
|
+
at first use rather than being buried inside every event."""
|
|
43
|
+
global _plugin
|
|
44
|
+
if _plugin is None:
|
|
45
|
+
_plugin = ExamplePlugin()
|
|
46
|
+
return _plugin
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@logger.inject_lambda_context
|
|
50
|
+
@tracer.capture_lambda_handler
|
|
51
|
+
def handler(event: dict, context: LambdaContext) -> dict:
|
|
52
|
+
logger.info("Received event", extra={"event": event})
|
|
53
|
+
|
|
54
|
+
global _loop
|
|
55
|
+
if _loop.is_closed():
|
|
56
|
+
_loop = asyncio.new_event_loop()
|
|
57
|
+
asyncio.set_event_loop(_loop)
|
|
58
|
+
|
|
59
|
+
biffo_event = create_event_handler(event)
|
|
60
|
+
plugin = _get_plugin()
|
|
61
|
+
_loop.run_until_complete(plugin.events.dispatch(biffo_event))
|
|
62
|
+
|
|
63
|
+
return {"statusCode": 200}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Path to this plugin's manifest file, shared by plugin.py and tests.
|
|
2
|
+
|
|
3
|
+
Mirrors the RBAC reference plugin's `services/rbac/src/rbac/manifest.py`
|
|
4
|
+
(PR #76) — same rationale: `plugin.py` and the tests both need a reliable
|
|
5
|
+
absolute path to `biffo.plugin.json` regardless of the current working
|
|
6
|
+
directory the process was started from.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
# src/example_plugin/manifest.py -> src/example_plugin -> src -> repo root
|
|
15
|
+
# (plugin root, where biffo.plugin.json lives alongside pyproject.toml).
|
|
16
|
+
def _resolve_manifest_path() -> Path:
|
|
17
|
+
# Walk up from this module to find biffo.plugin.json, so it resolves in both
|
|
18
|
+
# the repo layout (src/<pkg>/manifest.py, manifest at the plugin root) and the
|
|
19
|
+
# deployed Lambda (handler <pkg>.main.handler unzips <pkg>/ at the task root,
|
|
20
|
+
# manifest bundled at /var/task/biffo.plugin.json). A fixed parents[2]
|
|
21
|
+
# resolved to /var in the Lambda and raised FileNotFoundError on first invoke.
|
|
22
|
+
here = Path(__file__).resolve()
|
|
23
|
+
for base in here.parents:
|
|
24
|
+
candidate = base / "biffo.plugin.json"
|
|
25
|
+
if candidate.is_file():
|
|
26
|
+
return candidate
|
|
27
|
+
return here.parent.parent / "biffo.plugin.json"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
MANIFEST_PATH = _resolve_manifest_path()
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""ExamplePlugin — this plugin's BiffoPluginBase implementation.
|
|
2
|
+
|
|
3
|
+
Seeds a single default widget on install and reacts to
|
|
4
|
+
`biffo.core/UserCreated` by logging the new user. Replace both with real
|
|
5
|
+
logic for your own plugin — this exists to show a working, correctly-wired
|
|
6
|
+
skeleton: constructor -> manifest loading -> `self.api` -> `@self.subscribe`
|
|
7
|
+
-> `on_install`/`on_uninstall`, exercised end to end by the tests in
|
|
8
|
+
`tests/test_example_plugin.py`.
|
|
9
|
+
|
|
10
|
+
Modelled directly on the RBAC reference plugin's
|
|
11
|
+
`services/rbac/src/rbac/plugin.py` (PR #76) in the biffo-template monorepo —
|
|
12
|
+
see that file (and its README) for a more elaborate example with multiple
|
|
13
|
+
tables, an event-driven side effect, and an in-process (non-CRUD) helper.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import asyncio
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
from aws_lambda_powertools import Logger
|
|
22
|
+
from biffo_plugin_sdk import BiffoAPIClient, BiffoEvent, BiffoPluginBase, load_manifest
|
|
23
|
+
|
|
24
|
+
from .manifest import MANIFEST_PATH
|
|
25
|
+
|
|
26
|
+
logger = Logger()
|
|
27
|
+
|
|
28
|
+
_PLUGIN_BASE_PATH = "/api/v1/plugins/example-plugin"
|
|
29
|
+
|
|
30
|
+
# Seeded by on_install(). Real plugins would seed whatever baseline data
|
|
31
|
+
# their feature needs (RBAC's plugin.py seeds three roles, for example).
|
|
32
|
+
_DEFAULT_WIDGET: dict[str, Any] = {
|
|
33
|
+
"name": "starter-widget",
|
|
34
|
+
"description": "Created automatically by on_install(). Safe to delete.",
|
|
35
|
+
"is_active": True,
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class ExamplePlugin(BiffoPluginBase):
|
|
40
|
+
"""The example plugin shipped with the plugin repository template."""
|
|
41
|
+
|
|
42
|
+
def __init__(self, api: BiffoAPIClient | None = None) -> None:
|
|
43
|
+
manifest = load_manifest(MANIFEST_PATH)
|
|
44
|
+
super().__init__(manifest, api=api)
|
|
45
|
+
|
|
46
|
+
@self.subscribe("UserCreated")
|
|
47
|
+
async def _on_user_created(event: BiffoEvent) -> None:
|
|
48
|
+
await self._log_user_created(event)
|
|
49
|
+
|
|
50
|
+
def on_install(self) -> None:
|
|
51
|
+
"""Called by the CLI when the plugin is installed.
|
|
52
|
+
|
|
53
|
+
`BiffoPluginBase.on_install` is declared synchronous but
|
|
54
|
+
`BiffoAPIClient` is entirely async (same constraint the RBAC
|
|
55
|
+
reference plugin documents) — bridge with `asyncio.run` here and
|
|
56
|
+
keep the actual async logic in the public `seed_default_widget()`,
|
|
57
|
+
which tests and any future async caller can `await` directly.
|
|
58
|
+
"""
|
|
59
|
+
asyncio.run(self.seed_default_widget())
|
|
60
|
+
|
|
61
|
+
async def seed_default_widget(self) -> None:
|
|
62
|
+
"""The async core of on_install().
|
|
63
|
+
|
|
64
|
+
Generic CRUD (issue #19) has no upsert — every POST creates a new
|
|
65
|
+
row — so this lists existing widgets first and only creates the
|
|
66
|
+
default one if it's missing by name, making repeated installs a
|
|
67
|
+
no-op instead of creating duplicate rows.
|
|
68
|
+
"""
|
|
69
|
+
existing = await self.api.get(f"{_PLUGIN_BASE_PATH}/widgets")
|
|
70
|
+
existing_names = {row.get("name") for row in existing}
|
|
71
|
+
if _DEFAULT_WIDGET["name"] not in existing_names:
|
|
72
|
+
await self.api.post(f"{_PLUGIN_BASE_PATH}/widgets", json=dict(_DEFAULT_WIDGET))
|
|
73
|
+
|
|
74
|
+
def on_uninstall(self) -> None:
|
|
75
|
+
"""Called by the CLI when the plugin is uninstalled.
|
|
76
|
+
|
|
77
|
+
No-op: dropping this plugin's tables is the CLI's job (ADR-0003
|
|
78
|
+
section 9 — `biffo plugin uninstall` applies a generated migration
|
|
79
|
+
that drops them). Override this if your plugin needs additional
|
|
80
|
+
cleanup beyond that (e.g. deleting objects it created outside its
|
|
81
|
+
own tables).
|
|
82
|
+
"""
|
|
83
|
+
return None
|
|
84
|
+
|
|
85
|
+
async def _log_user_created(self, event: BiffoEvent) -> None:
|
|
86
|
+
"""Example event handler. Replace with real logic, or remove the
|
|
87
|
+
`@self.subscribe` registration in `__init__` if your plugin doesn't
|
|
88
|
+
need to react to this event."""
|
|
89
|
+
logger.info(
|
|
90
|
+
"New user created",
|
|
91
|
+
extra={"tenant_id": event.tenant_id, "payload": event.payload},
|
|
92
|
+
)
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
# `terraform/` — this plugin's infrastructure
|
|
2
|
+
|
|
3
|
+
This is the plugin's own Terraform module, per [ADR-0003](https://github.com/keiranholloway/biffo-template/blob/main/docs/ADR/0003-plugin-system-and-marketplace.md) section 2. It is a verbatim copy of biffo-template's `modules/plugins/_template/`, kept here so a plugin built from this skeleton is **deployable out of the box**: without it, the `event_subscriptions` in `biffo.plugin.json` are declared, handled in code, unit-tested — and inert in every real deployment, because nothing creates the Lambda or the EventBridge rule to fire them.
|
|
4
|
+
|
|
5
|
+
`biffo plugin install <name>@<minor>` copies this directory into the user's monorepo at `modules/plugins/<name>/`. Note that the install path is where it becomes valid Terraform: `main.tf`'s `source = "../../cloud/aws/compute"` is relative to `modules/plugins/<name>/`, so `terraform validate` does **not** run against this directory in place — neither here nor in biffo-template, whose Terraform CI job scopes itself to `infra/` and `modules/`. Do not "fix" that path; changing it breaks the install target.
|
|
6
|
+
|
|
7
|
+
Rename `example_plugin`/`<name>` references to your plugin's slug and adjust `handler`, `event_subscriptions`, and `memory_size`/`timeout` to match your manifest. The rest of this file is the module's own documentation, unchanged.
|
|
8
|
+
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
## Original module notes
|
|
12
|
+
|
|
13
|
+
## What it wraps, and why
|
|
14
|
+
|
|
15
|
+
This module is a thin wrapper around two existing modules, not a reimplementation:
|
|
16
|
+
|
|
17
|
+
- **`modules/cloud/aws/compute`** — the plugin's Lambda function (IAM role, DLQ, log group, tracing — the same baseline every Biffo function gets).
|
|
18
|
+
- **`modules/cloud/aws/events`**, via the `event_bus_name` passed in from the root config — this module only adds the EventBridge rule/target/permission needed to route the plugin's declared `event_subscriptions` to its Lambda. It never creates a bus of its own.
|
|
19
|
+
|
|
20
|
+
Per **ADR-0002** ("no DB clients outside `services/api/`; microservices call the API via HTTP and react to EventBridge events"), a plugin module:
|
|
21
|
+
|
|
22
|
+
- **Never receives `db_credentials_secret_arn`.** Only the Core API's Lambda (`module.core_api` in the root config) gets that grant. A plugin that needs platform data calls the Core API over HTTPS using `BIFFO_CORE_API_URL` (wired here from `var.core_api_url`) via the plugin SDK's Core client — see [Calling the Core API](#calling-the-core-api-adr-0009) below for how that call is authenticated.
|
|
23
|
+
- **Is not VPC-attached by default.** `enable_vpc_access` defaults to `false` in both this module and `modules/cloud/aws/compute`, so the plugin's Lambda runs with normal Lambda internet egress — required to reach the Core API's public endpoint, especially in NAT-less environments like dev (`enable_nat_gateway = false`). Only set `enable_vpc_access = true` (and pass `vpc_id`/`private_subnet_ids`) if the plugin has a genuine ADR-0002-compliant reason to reach a VPC-only resource (e.g. ElastiCache) — never to reach the database directly. `enable_vpc_access` must be an explicit boolean, not inferred from `vpc_id` — Terraform's `count`/`for_each` can't depend on a `vpc_id` that's only known after apply.
|
|
24
|
+
|
|
25
|
+
## Variables
|
|
26
|
+
|
|
27
|
+
See `variables.tf`. The standard set every plugin module receives from the root config: `project_name`, `environment`, `plugin_name`, `event_bus_name`, `core_api_url`, `tags`, plus `core_api_execution_arn` for any plugin that calls the Core API. Everything else (`handler`, `event_subscriptions`, `environment_variables`, etc.) is plugin-specific.
|
|
28
|
+
|
|
29
|
+
## Calling the Core API (ADR-0009)
|
|
30
|
+
|
|
31
|
+
A plugin may not touch the database (ADR-0002), so anything it needs from the
|
|
32
|
+
platform it fetches from the Core API. The Core API's **internal** routes
|
|
33
|
+
(`/api/v1/internal/*`) are authorized by **IAM SigV4**, not by a Cognito JWT —
|
|
34
|
+
per [ADR-0009](https://github.com/keiranholloway/biffo-template/blob/main/docs/ADR/0009-internal-service-authentication.md). There
|
|
35
|
+
is no bearer token to issue, store, or rotate; the credential is the plugin
|
|
36
|
+
Lambda's own role.
|
|
37
|
+
|
|
38
|
+
Three things must line up, and all three are wired for you — this module
|
|
39
|
+
does the first two, and `biffo plugin install` does the third:
|
|
40
|
+
|
|
41
|
+
1. **Sign the request.** The plugin SDK's `SignedCoreClient` does this, and
|
|
42
|
+
`BiffoPluginBase` builds one by default (`create_core_client()`), so plugin
|
|
43
|
+
code that just calls `self.api.post(...)` is already signing. `botocore` is
|
|
44
|
+
preinstalled in the Lambda Python runtime; outside Lambda install the
|
|
45
|
+
`biffo-plugin-sdk[sigv4]` extra.
|
|
46
|
+
2. **Grant `execute-api:Invoke`.** Set `core_api_execution_arn` (the root
|
|
47
|
+
config's `module.api_gateway.execution_arn`) and this module attaches an
|
|
48
|
+
inline policy to the plugin's Lambda role allowing `execute-api:Invoke` on
|
|
49
|
+
`<execution_arn>/*/*/api/v1/internal/*` — that prefix only, never the whole
|
|
50
|
+
API. Leave the variable empty and no grant is created.
|
|
51
|
+
3. **Allowlist the role on the Core API.** _Automatic since issue #201._ The
|
|
52
|
+
Core API independently re-checks the resolved caller ARN against
|
|
53
|
+
`BIFFO_SERVICE_PRINCIPAL_ARN_ALLOWLIST` and **fails closed** on an empty or
|
|
54
|
+
non-matching allowlist, so step 2 alone would get you a `403`. At runtime the
|
|
55
|
+
caller ARN is the assumed-role session form, so the allowlist entry is a
|
|
56
|
+
glob:
|
|
57
|
+
|
|
58
|
+
```
|
|
59
|
+
arn:aws:sts::<account-id>:assumed-role/<project>-<env>-plugin-<name>-role/*
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
The template-owned `modules/cloud/aws/plugin-allowlist` module builds that
|
|
63
|
+
list, mapping over the root config's `var.enabled_plugins`
|
|
64
|
+
— so adding the plugin to `enabled_plugins` (which `biffo plugin install`
|
|
65
|
+
does for you, via the generated `plugins.auto.tfvars.json`) is what
|
|
66
|
+
allowlists it. Nothing to copy by hand, and the grant and the allowlist
|
|
67
|
+
cannot drift apart.
|
|
68
|
+
|
|
69
|
+
The role name is deterministic, which is what makes this possible:
|
|
70
|
+
`modules/cloud/aws/compute` names the role `<function_name>-role`, where
|
|
71
|
+
`function_name` is `<project_name>-<environment>-plugin-<plugin_name>`. It is
|
|
72
|
+
also this module's `role_name` output — useful for auditing, but the root
|
|
73
|
+
config deliberately does not read it (see below).
|
|
74
|
+
|
|
75
|
+
### Why the allowlist is a static string, not this module's output
|
|
76
|
+
|
|
77
|
+
Tempting as it is to write
|
|
78
|
+
`BIFFO_SERVICE_PRINCIPAL_ARN_ALLOWLIST = module.plugin_<name>.role_arn`, that
|
|
79
|
+
creates a Terraform **dependency cycle** (issue
|
|
80
|
+
[#201](https://github.com/keiranholloway/biffo-template/issues/201)):
|
|
81
|
+
|
|
82
|
+
```
|
|
83
|
+
core_api -> api_gateway -> plugin -> core_api
|
|
84
|
+
(needs the plugin's role ARN) (needs the API's execution ARN)
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
The plugin needs the API Gateway's `execution_arn` to scope its grant, and the
|
|
88
|
+
Core API Lambda would need the plugin's `role_arn` for its allowlist env var.
|
|
89
|
+
Terraform cannot order that.
|
|
90
|
+
|
|
91
|
+
The resolution — the same one the orchestrator uses — is that the arrow only
|
|
92
|
+
ever points **one way**: API Gateway -> plugin. The allowlist entry is written as
|
|
93
|
+
the _predictable_ role-name glob above, interpolated from values the root config
|
|
94
|
+
already knows (`project_name`, `environment`, plugin name) rather than read back
|
|
95
|
+
out of the plugin module.
|
|
96
|
+
|
|
97
|
+
**How close the cycle actually is.** Measured on the current module, wiring
|
|
98
|
+
`role_arn` does _not_ deadlock today: Terraform's dependency graph is
|
|
99
|
+
resource-level, not module-level, and this module's `aws_iam_role` does not
|
|
100
|
+
itself depend on API Gateway — only the separate `aws_iam_role_policy.core_api`
|
|
101
|
+
does. So a `terraform plan` with `role_arn` wired in currently builds. That is
|
|
102
|
+
an accident of this module's internals, not a property you can rely on: a plugin
|
|
103
|
+
that attached its Core API policy to the role resource itself (an inline
|
|
104
|
+
`inline_policy` block rather than a separate `aws_iam_role_policy`) closes the
|
|
105
|
+
loop immediately, and the resulting cycle error lands on whoever _installed_
|
|
106
|
+
that plugin, far from the code that caused it. It would also make the Core API
|
|
107
|
+
un-plannable whenever any installed plugin module is broken.
|
|
108
|
+
|
|
109
|
+
The static glob has no dependency on the plugin module at all, for any plugin,
|
|
110
|
+
which is the property worth having. Keep it that way.
|
|
111
|
+
|
|
112
|
+
## Loose coupling
|
|
113
|
+
|
|
114
|
+
This module must never reference another plugin's module or resources. Each plugin is instantiated independently by the root config's `for_each`-gated `module "plugin_<name>"` block — no plugin module may assume any other plugin is installed.
|
|
115
|
+
|
|
116
|
+
## Wiring into the root config
|
|
117
|
+
|
|
118
|
+
`biffo plugin install` generates this block for you, into a CLI-owned
|
|
119
|
+
`infra/environments/<env>/plugins.generated.tf` (issue #201) — you should not
|
|
120
|
+
need to write it by hand. It is reproduced here so you know what your module is
|
|
121
|
+
instantiated with.
|
|
122
|
+
|
|
123
|
+
The root config cannot dynamically discover plugin module directories —
|
|
124
|
+
Terraform requires a module's `source` argument to be a static string literal,
|
|
125
|
+
so each plugin needs its own explicit block, gated on membership in
|
|
126
|
+
`enabled_plugins`:
|
|
127
|
+
|
|
128
|
+
```hcl
|
|
129
|
+
module "plugin_<name>" {
|
|
130
|
+
source = "../../../modules/plugins/<name>"
|
|
131
|
+
for_each = contains(var.enabled_plugins, "<name>") ? { "<name>" = true } : {}
|
|
132
|
+
|
|
133
|
+
project_name = var.project_name
|
|
134
|
+
environment = local.environment
|
|
135
|
+
plugin_name = "<name>"
|
|
136
|
+
handler = "src.lambda.main.handler"
|
|
137
|
+
event_bus_name = module.events.event_bus_name
|
|
138
|
+
core_api_url = module.api_gateway.api_endpoint
|
|
139
|
+
tags = local.tags
|
|
140
|
+
|
|
141
|
+
# Only if the plugin calls the Core API — see "Calling the Core API" below.
|
|
142
|
+
core_api_execution_arn = module.api_gateway.execution_arn
|
|
143
|
+
}
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
The generator emits only the arguments your module actually declares a `variable` block for, so a module predating one of these inputs still wires in cleanly. See `infra/environments/dev/README.md` for the full convention.
|