@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,167 @@
|
|
|
1
|
+
# Template Terraform module for a Biffo plugin (ADR-0003 chunk 12 / issues #25, #201).
|
|
2
|
+
#
|
|
3
|
+
# Copy this directory to modules/plugins/<name>/ inside a plugin repo (as
|
|
4
|
+
# terraform/, per ADR-0003 section 2's plugin repo layout) and adjust as
|
|
5
|
+
# needed. `biffo plugin install <name>@<minor>` copies that terraform/
|
|
6
|
+
# directory into the user's monorepo at modules/plugins/<name>/ and then
|
|
7
|
+
# generates the instantiating `module "plugin_<name>"` block — gated on
|
|
8
|
+
# `enabled_plugins` — into infra/environments/<env>/plugins.generated.tf. That
|
|
9
|
+
# file is CLI-owned and regenerated on every install/uninstall; main.tf is
|
|
10
|
+
# never edited. See infra/environments/dev/README.md's "Adding a plugin".
|
|
11
|
+
#
|
|
12
|
+
# This module deliberately wraps two existing modules rather than
|
|
13
|
+
# reimplementing Lambda/IAM/EventBridge from scratch:
|
|
14
|
+
# - modules/cloud/aws/compute — the plugin's Lambda function, with the
|
|
15
|
+
# same DLQ/logging/tracing/least-privilege IAM baseline every Biffo
|
|
16
|
+
# function gets.
|
|
17
|
+
# - modules/cloud/aws/events (via the shared event_bus_name passed in) —
|
|
18
|
+
# this module adds only the subscription rule/target/permission a
|
|
19
|
+
# plugin needs to react to events on the bus the root config already
|
|
20
|
+
# owns. No new bus is created.
|
|
21
|
+
#
|
|
22
|
+
# What this module does NOT do, per ADR-0002 ("no DB clients outside
|
|
23
|
+
# services/api/", "microservices call the API via HTTP and react to
|
|
24
|
+
# EventBridge events"):
|
|
25
|
+
# - It never creates a database, and never receives
|
|
26
|
+
# db_credentials_secret_arn — that variable is wired to the Core API's
|
|
27
|
+
# Lambda only (infra/environments/dev/main.tf's module "core_api" block).
|
|
28
|
+
# A plugin that needs platform data calls the Core API over HTTPS
|
|
29
|
+
# (BIFFO_CORE_API_URL, see variables.tf) using the plugin SDK's
|
|
30
|
+
# BiffoAPIClient, exactly like any other API consumer.
|
|
31
|
+
# - It never attaches to the VPC unless var.enable_vpc_access is set to
|
|
32
|
+
# true — see variables.tf's enable_vpc_access description for why.
|
|
33
|
+
#
|
|
34
|
+
# Loose coupling: this module must not reference other plugin modules or
|
|
35
|
+
# their resources. Each plugin is instantiated independently by the root
|
|
36
|
+
# config; nothing here should assume any other plugin is installed.
|
|
37
|
+
|
|
38
|
+
terraform {
|
|
39
|
+
required_providers {
|
|
40
|
+
aws = { source = "hashicorp/aws", version = "~> 5.0" }
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
locals {
|
|
45
|
+
name_prefix = "${var.project_name}-${var.environment}"
|
|
46
|
+
function_name = "${local.name_prefix}-plugin-${var.plugin_name}"
|
|
47
|
+
# A rule is created when the plugin subscribes to specific events, or when it
|
|
48
|
+
# is a generic forwarder that reacts to every event (subscribe_all).
|
|
49
|
+
has_subscriptions = var.subscribe_all || length(var.event_subscriptions) > 0
|
|
50
|
+
# Grant Core API access only when the root config told us which API to scope
|
|
51
|
+
# it to. Empty (the default) => the plugin never calls Core, so no grant.
|
|
52
|
+
grants_core_api_access = var.core_api_execution_arn != ""
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
# Compute — the plugin's Lambda function.
|
|
56
|
+
module "function" {
|
|
57
|
+
source = "../../cloud/aws/compute"
|
|
58
|
+
|
|
59
|
+
project_name = var.project_name
|
|
60
|
+
environment = var.environment
|
|
61
|
+
function_name = "plugin-${var.plugin_name}"
|
|
62
|
+
handler = var.handler
|
|
63
|
+
runtime = var.runtime
|
|
64
|
+
memory_size = var.memory_size
|
|
65
|
+
timeout = var.timeout
|
|
66
|
+
enable_vpc_access = var.enable_vpc_access
|
|
67
|
+
vpc_id = var.vpc_id
|
|
68
|
+
private_subnet_ids = var.private_subnet_ids
|
|
69
|
+
event_bus_name = var.event_bus_name
|
|
70
|
+
|
|
71
|
+
# No db_credentials_secret_arn — see the ADR-0002 note above.
|
|
72
|
+
environment_variables = merge(
|
|
73
|
+
{
|
|
74
|
+
BIFFO_CORE_API_URL = var.core_api_url
|
|
75
|
+
BIFFO_PLUGIN_NAME = var.plugin_name
|
|
76
|
+
},
|
|
77
|
+
var.environment_variables,
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
sqs_kms_key_id = var.sqs_kms_key_id
|
|
81
|
+
cloudwatch_kms_key_id = var.cloudwatch_kms_key_id
|
|
82
|
+
tags = var.tags
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
# Events — subscribe the plugin's Lambda to its declared event_subscriptions
|
|
86
|
+
# on the shared bus. Each subscription is matched as its own source +
|
|
87
|
+
# detail-type pair via `$or`, rather than independent `source`/`detail-type`
|
|
88
|
+
# arrays, to avoid EventBridge matching the cross product of unrelated
|
|
89
|
+
# source/detail-type combinations when a plugin subscribes to more than one
|
|
90
|
+
# event.
|
|
91
|
+
resource "aws_cloudwatch_event_rule" "subscription" {
|
|
92
|
+
count = local.has_subscriptions ? 1 : 0
|
|
93
|
+
name = "${local.function_name}-events"
|
|
94
|
+
description = "Routes subscribed events to the ${var.plugin_name} plugin"
|
|
95
|
+
event_bus_name = var.event_bus_name
|
|
96
|
+
|
|
97
|
+
# subscribe_all → match every event on the bus (a generic forwarder; the plugin
|
|
98
|
+
# decides what to do, so new triggers need no Terraform change — ADR-0010). Else
|
|
99
|
+
# a single subscription uses a flat pattern and two or more are OR-ed: EventBridge
|
|
100
|
+
# rejects a `$or` with fewer than 2 elements ("There must have at least 2 Objects
|
|
101
|
+
# in $or relationship"), so the single-subscription case must not use it.
|
|
102
|
+
# jsonencode is applied inside each branch so the conditional's arms are all
|
|
103
|
+
# strings — a `cond ? {a} : {b}` on differently-shaped objects is an "Inconsistent
|
|
104
|
+
# conditional result types" error at apply (validate misses it).
|
|
105
|
+
event_pattern = var.subscribe_all ? jsonencode({
|
|
106
|
+
source = [{ prefix = "" }]
|
|
107
|
+
}) : length(var.event_subscriptions) == 1 ? jsonencode({
|
|
108
|
+
source = [var.event_subscriptions[0].source]
|
|
109
|
+
"detail-type" = [var.event_subscriptions[0].detail_type]
|
|
110
|
+
}) : jsonencode({
|
|
111
|
+
"$or" = [
|
|
112
|
+
for s in var.event_subscriptions : {
|
|
113
|
+
source = [s.source]
|
|
114
|
+
"detail-type" = [s.detail_type]
|
|
115
|
+
}
|
|
116
|
+
]
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
tags = var.tags
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
resource "aws_cloudwatch_event_target" "subscription" {
|
|
123
|
+
count = local.has_subscriptions ? 1 : 0
|
|
124
|
+
rule = aws_cloudwatch_event_rule.subscription[0].name
|
|
125
|
+
event_bus_name = var.event_bus_name
|
|
126
|
+
target_id = "${var.plugin_name}-lambda"
|
|
127
|
+
arn = module.function.function_arn
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
resource "aws_lambda_permission" "subscription" {
|
|
131
|
+
count = local.has_subscriptions ? 1 : 0
|
|
132
|
+
statement_id = "AllowEventBridgeInvoke"
|
|
133
|
+
action = "lambda:InvokeFunction"
|
|
134
|
+
function_name = module.function.function_name
|
|
135
|
+
principal = "events.amazonaws.com"
|
|
136
|
+
source_arn = aws_cloudwatch_event_rule.subscription[0].arn
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
# Core API access (ADR-0009) — the plugin->Core auth path.
|
|
140
|
+
#
|
|
141
|
+
# ADR-0002 forbids this Lambda from touching the database, so anything it needs
|
|
142
|
+
# from the platform it gets over HTTPS from the Core API. The Core API's
|
|
143
|
+
# internal routes (/api/v1/internal/*) are IAM-authorized, not Cognito-JWT, so
|
|
144
|
+
# the plugin authenticates by SigV4-signing with this Lambda role — see
|
|
145
|
+
# biffo_plugin_sdk.SignedCoreClient, which BiffoPluginBase uses by default. No
|
|
146
|
+
# bearer token, no shared secret, nothing to rotate.
|
|
147
|
+
#
|
|
148
|
+
# Scoped to the /api/v1/internal/* prefix on one API, never the whole API.
|
|
149
|
+
data "aws_iam_policy_document" "core_api" {
|
|
150
|
+
count = local.grants_core_api_access ? 1 : 0
|
|
151
|
+
|
|
152
|
+
statement {
|
|
153
|
+
sid = "InvokeCoreInternalApi"
|
|
154
|
+
effect = "Allow"
|
|
155
|
+
actions = ["execute-api:Invoke"]
|
|
156
|
+
resources = ["${var.core_api_execution_arn}/*/*/api/v1/internal/*"]
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
resource "aws_iam_role_policy" "core_api" {
|
|
161
|
+
count = local.grants_core_api_access ? 1 : 0
|
|
162
|
+
name = "${local.function_name}-core-api"
|
|
163
|
+
# compute exposes the role via its ARN; derive the role name (last ARN
|
|
164
|
+
# segment) since aws_iam_role_policy wants the name, not the ARN.
|
|
165
|
+
role = element(split("/", module.function.role_arn), length(split("/", module.function.role_arn)) - 1)
|
|
166
|
+
policy = data.aws_iam_policy_document.core_api[0].json
|
|
167
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
output "function_arn" {
|
|
2
|
+
description = "Lambda function ARN — aggregate this in the root config's plugin outputs."
|
|
3
|
+
value = module.function.function_arn
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
output "function_name" {
|
|
7
|
+
value = module.function.function_name
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
# The plugin Lambda's execution role ARN. Useful for auditing, but note this is
|
|
11
|
+
# deliberately NOT how the role reaches the Core API's
|
|
12
|
+
# BIFFO_SERVICE_PRINCIPAL_ARN_ALLOWLIST — wiring this output into the core_api
|
|
13
|
+
# module would create the dependency cycle core_api -> api_gateway -> plugin ->
|
|
14
|
+
# core_api (issue #201). Allowlist the predictable assumed-role glob instead;
|
|
15
|
+
# see README.md.
|
|
16
|
+
output "role_arn" {
|
|
17
|
+
value = module.function.role_arn
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
output "role_name" {
|
|
21
|
+
description = "The plugin Lambda's execution role NAME — the value to interpolate into the Core API's BIFFO_SERVICE_PRINCIPAL_ARN_ALLOWLIST glob (arn:aws:sts::<acct>:assumed-role/<role-name>/*)."
|
|
22
|
+
value = element(split("/", module.function.role_arn), length(split("/", module.function.role_arn)) - 1)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
output "dlq_arn" {
|
|
26
|
+
description = "Dead letter queue ARN for failed invocations (both direct and EventBridge-triggered)."
|
|
27
|
+
value = module.function.dlq_arn
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
output "event_rule_arn" {
|
|
31
|
+
description = "EventBridge rule ARN, or null when the plugin declares no event_subscriptions."
|
|
32
|
+
value = local.has_subscriptions ? aws_cloudwatch_event_rule.subscription[0].arn : null
|
|
33
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
variable "project_name" {
|
|
2
|
+
description = "Biffo project name — passed through unchanged from the root config."
|
|
3
|
+
type = string
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
variable "environment" {
|
|
7
|
+
description = "Deployment environment (dev/staging/prod) — passed through unchanged from the root config."
|
|
8
|
+
type = string
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
variable "plugin_name" {
|
|
12
|
+
description = "Plugin slug, matching biffo.plugin.json's `name` field and the services/<name>/ directory this plugin was installed into (ADR-0003 section 2). Used to namespace every resource this module creates."
|
|
13
|
+
type = string
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
variable "handler" {
|
|
17
|
+
description = "Lambda handler entrypoint, e.g. `src.lambda.main.handler` per the plugin repo layout in ADR-0003 section 2."
|
|
18
|
+
type = string
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
variable "runtime" {
|
|
22
|
+
type = string
|
|
23
|
+
default = "python3.13"
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
variable "memory_size" {
|
|
27
|
+
type = number
|
|
28
|
+
default = 512
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
variable "timeout" {
|
|
32
|
+
type = number
|
|
33
|
+
default = 30
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
variable "enable_vpc_access" {
|
|
37
|
+
description = <<-EOT
|
|
38
|
+
Attach this plugin's Lambda to a VPC. Leave false (the default) — per
|
|
39
|
+
ADR-0002, plugins never access the database directly, only the Core API
|
|
40
|
+
over HTTPS and EventBridge, so VPC attachment is normally unnecessary.
|
|
41
|
+
In NAT-less networking configs (e.g. dev's enable_nat_gateway = false),
|
|
42
|
+
attaching to the VPC would also cut off the outbound internet access
|
|
43
|
+
this Lambda needs to reach the Core API's public endpoint. Only enable
|
|
44
|
+
this if the plugin has a genuine, ADR-0002-compliant reason to reach a
|
|
45
|
+
VPC-only resource (e.g. ElastiCache) — never to reach the database.
|
|
46
|
+
|
|
47
|
+
Must be an explicit boolean, not inferred from whether vpc_id is set —
|
|
48
|
+
vpc_id can be only known after apply (e.g. if it were ever passed from
|
|
49
|
+
a VPC created in the same plan), and Terraform's count/for_each in the
|
|
50
|
+
underlying compute module cannot depend on a not-yet-known value.
|
|
51
|
+
EOT
|
|
52
|
+
type = bool
|
|
53
|
+
default = false
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
variable "vpc_id" {
|
|
57
|
+
description = "VPC to attach this plugin's Lambda to. Only used when enable_vpc_access is true."
|
|
58
|
+
type = string
|
|
59
|
+
default = ""
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
variable "private_subnet_ids" {
|
|
63
|
+
description = "Private subnets to place ENIs in. Only used when enable_vpc_access is true."
|
|
64
|
+
type = list(string)
|
|
65
|
+
default = []
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
variable "core_api_url" {
|
|
69
|
+
description = "Core API base URL (module.api_gateway.api_endpoint from the root config). Injected as BIFFO_CORE_API_URL — the plugin SDK's BiffoAPIClient reads this env var by default (packages/python-sdk/src/biffo_plugin_sdk/client.py). This is how plugins read/write platform data per ADR-0002 — never a direct DB connection."
|
|
70
|
+
type = string
|
|
71
|
+
default = ""
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
variable "core_api_execution_arn" {
|
|
75
|
+
description = <<-EOT
|
|
76
|
+
The Core API Gateway's execution ARN (module.api_gateway.execution_arn from
|
|
77
|
+
the root config, i.e. arn:aws:execute-api:<region>:<acct>:<api-id>). When
|
|
78
|
+
set, this plugin's Lambda role is granted execute-api:Invoke scoped to that
|
|
79
|
+
API's /api/v1/internal/* routes only — the plugin->Core auth mechanism from
|
|
80
|
+
ADR-0009 (IAM SigV4), which the plugin SDK's SignedCoreClient speaks.
|
|
81
|
+
|
|
82
|
+
Leave empty (the default) only if the plugin never calls the Core API. It
|
|
83
|
+
is not enough on its own: the Core API independently re-checks the caller
|
|
84
|
+
against BIFFO_SERVICE_PRINCIPAL_ARN_ALLOWLIST, so the plugin's role must
|
|
85
|
+
also be allowlisted there — see this module's README for the exact glob and
|
|
86
|
+
why it is a static string rather than this module's role_arn output.
|
|
87
|
+
EOT
|
|
88
|
+
type = string
|
|
89
|
+
default = ""
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
variable "event_bus_name" {
|
|
93
|
+
description = "Name of the shared EventBridge bus (module.events.event_bus_name from the root config). This module subscribes to it — it never creates its own bus, keeping every plugin's events on one platform-wide bus per ADR-0002."
|
|
94
|
+
type = string
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
variable "event_subscriptions" {
|
|
98
|
+
description = "Events this plugin reacts to, mirroring biffo.plugin.json's `event_subscriptions` array (ADR-0003 section 2), e.g. [{ source = \"biffo.core\", detail_type = \"UserCreated\" }]. Leave empty if the plugin only calls the Core API and never reacts to events — no EventBridge rule is created in that case. Ignored when `subscribe_all` is true."
|
|
99
|
+
type = list(object({
|
|
100
|
+
source = string
|
|
101
|
+
detail_type = string
|
|
102
|
+
}))
|
|
103
|
+
default = []
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
variable "subscribe_all" {
|
|
107
|
+
description = "Subscribe to every event on the bus (a generic forwarder). When true, `event_subscriptions` is ignored and the rule matches all events, so the plugin — not Terraform — decides what to act on and a new trigger needs no infra change (ADR-0010). Use for engines like the orchestrator that forward everything to the Core API for matching."
|
|
108
|
+
type = bool
|
|
109
|
+
default = false
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
variable "environment_variables" {
|
|
113
|
+
description = "Additional environment variables for the plugin's Lambda, merged over BIFFO_CORE_API_URL / BIFFO_PLUGIN_NAME. Do not put database connection details here — plugins never receive them (ADR-0002)."
|
|
114
|
+
type = map(string)
|
|
115
|
+
default = {}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
variable "sqs_kms_key_id" {
|
|
119
|
+
description = "KMS key ID for the DLQ's SQS queue encryption (CKV_AWS_27). Leave empty for AWS-owned key."
|
|
120
|
+
type = string
|
|
121
|
+
default = ""
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
variable "cloudwatch_kms_key_id" {
|
|
125
|
+
description = "KMS key ID for CloudWatch log group encryption (CKV_AWS_158). Leave empty for AWS-owned key."
|
|
126
|
+
type = string
|
|
127
|
+
default = ""
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
variable "tags" {
|
|
131
|
+
type = map(string)
|
|
132
|
+
default = {}
|
|
133
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Make `example_plugin` and the test-local `fakes` module importable.
|
|
2
|
+
|
|
3
|
+
This template is a standalone repo (not part of the biffo-template
|
|
4
|
+
monorepo's uv workspace — see README's "Standalone repo" note), so
|
|
5
|
+
`example_plugin` is installed normally via the project's own
|
|
6
|
+
[build-system]/hatchling config when `uv sync` runs. This conftest only
|
|
7
|
+
needs to put `tests/` itself on sys.path so `tests/test_example_plugin.py`
|
|
8
|
+
can `import fakes` as a plain sibling module (pytest's rootdir insertion
|
|
9
|
+
already covers this in most configurations, but the explicit path insert
|
|
10
|
+
keeps this working from any cwd, matching the RBAC reference plugin's
|
|
11
|
+
`services/rbac/tests/conftest.py` pattern in the biffo-template monorepo).
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import sys
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
sys.path.insert(0, str(Path(__file__).parent))
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""A tiny in-memory fake of the Core API's generic CRUD routes
|
|
2
|
+
(`/api/v1/plugins/example-plugin/*`), used to test this plugin without a
|
|
3
|
+
real Core API or network call.
|
|
4
|
+
|
|
5
|
+
Mirrors the shape `build_plugin_router` in
|
|
6
|
+
`services/api/src/api/routing/plugin_router.py` (biffo-template monorepo)
|
|
7
|
+
actually produces (id auto-assigned, tenant_id auto-assigned, list/create/
|
|
8
|
+
read/delete by path), so these tests exercise realistic request/response
|
|
9
|
+
semantics rather than hand-picked canned responses. Copied from the RBAC
|
|
10
|
+
reference plugin's `services/rbac/tests/fakes.py` (PR #76) and trimmed to
|
|
11
|
+
this plugin's single `widgets` resource.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
from itertools import count
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
import httpx
|
|
21
|
+
from biffo_plugin_sdk import BiffoAPIClient
|
|
22
|
+
|
|
23
|
+
_BASE_PATH = "/api/v1/plugins/example-plugin"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class FakeCoreApi:
|
|
27
|
+
"""Fake backing store for the plugin's own generic CRUD routes."""
|
|
28
|
+
|
|
29
|
+
def __init__(self) -> None:
|
|
30
|
+
self.tables: dict[str, list[dict[str, Any]]] = {"widgets": []}
|
|
31
|
+
self.request_log: list[tuple[str, str]] = []
|
|
32
|
+
self._ids = count(1)
|
|
33
|
+
|
|
34
|
+
def client(self) -> BiffoAPIClient:
|
|
35
|
+
transport = httpx.MockTransport(self._handle)
|
|
36
|
+
async_client = httpx.AsyncClient(transport=transport)
|
|
37
|
+
return BiffoAPIClient(
|
|
38
|
+
base_url="https://core.example.com", token="test-jwt", client=async_client
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
def _handle(self, request: httpx.Request) -> httpx.Response:
|
|
42
|
+
path = request.url.path
|
|
43
|
+
assert path.startswith(_BASE_PATH), f"unexpected path: {path}"
|
|
44
|
+
self.request_log.append((request.method, path))
|
|
45
|
+
|
|
46
|
+
rest = path[len(_BASE_PATH) :].strip("/")
|
|
47
|
+
parts = rest.split("/") if rest else []
|
|
48
|
+
table = parts[0]
|
|
49
|
+
row_id = parts[1] if len(parts) > 1 else None
|
|
50
|
+
rows = self.tables.setdefault(table, [])
|
|
51
|
+
|
|
52
|
+
if request.method == "GET" and row_id is None:
|
|
53
|
+
return httpx.Response(200, json=rows)
|
|
54
|
+
|
|
55
|
+
if request.method == "GET" and row_id is not None:
|
|
56
|
+
row = next((r for r in rows if r["id"] == row_id), None)
|
|
57
|
+
if row is None:
|
|
58
|
+
return httpx.Response(404, json={"detail": "Not found"})
|
|
59
|
+
return httpx.Response(200, json=row)
|
|
60
|
+
|
|
61
|
+
if request.method == "POST":
|
|
62
|
+
payload = json.loads(request.content or b"{}")
|
|
63
|
+
row = {"id": f"id-{next(self._ids)}", "tenant_id": "default", **payload}
|
|
64
|
+
rows.append(row)
|
|
65
|
+
return httpx.Response(201, json=row)
|
|
66
|
+
|
|
67
|
+
if request.method == "DELETE" and row_id is not None:
|
|
68
|
+
before = len(rows)
|
|
69
|
+
self.tables[table] = [r for r in rows if r["id"] != row_id]
|
|
70
|
+
if len(self.tables[table]) == before:
|
|
71
|
+
return httpx.Response(404, json={"detail": "Not found"})
|
|
72
|
+
return httpx.Response(200, json={"deleted": True, "id": row_id})
|
|
73
|
+
|
|
74
|
+
return httpx.Response(404, json={"detail": "Not found"})
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""Tests for ExamplePlugin: manifest loading, lifecycle hooks, and the
|
|
2
|
+
UserCreated event subscription.
|
|
3
|
+
|
|
4
|
+
Modelled on the RBAC reference plugin's
|
|
5
|
+
`services/rbac/tests/test_rbac_plugin.py` (PR #76) in the biffo-template
|
|
6
|
+
monorepo.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from biffo_plugin_sdk import BiffoEvent
|
|
12
|
+
from fakes import FakeCoreApi
|
|
13
|
+
|
|
14
|
+
from example_plugin.plugin import _DEFAULT_WIDGET, ExamplePlugin
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _make_plugin() -> tuple[ExamplePlugin, FakeCoreApi]:
|
|
18
|
+
fake = FakeCoreApi()
|
|
19
|
+
plugin = ExamplePlugin(api=fake.client())
|
|
20
|
+
return plugin, fake
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class TestManifestLoading:
|
|
24
|
+
def test_manifest_name_and_version(self) -> None:
|
|
25
|
+
plugin, _ = _make_plugin()
|
|
26
|
+
assert plugin.manifest.name == "example-plugin"
|
|
27
|
+
assert plugin.manifest.version == "0.1.0"
|
|
28
|
+
|
|
29
|
+
def test_manifest_declares_the_widgets_table(self) -> None:
|
|
30
|
+
plugin, _ = _make_plugin()
|
|
31
|
+
table_names = {t.name for t in plugin.manifest.tables}
|
|
32
|
+
assert table_names == {"example_widgets"}
|
|
33
|
+
|
|
34
|
+
def test_register_returns_manifest_registration(self) -> None:
|
|
35
|
+
plugin, _ = _make_plugin()
|
|
36
|
+
registration = plugin.register()
|
|
37
|
+
assert registration["name"] == "example-plugin"
|
|
38
|
+
assert {t["name"] for t in registration["tables"]} == {"example_widgets"}
|
|
39
|
+
assert {r["path"] for r in registration["api_routes"]} == {
|
|
40
|
+
"/widgets",
|
|
41
|
+
"/widgets/{id}",
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class TestSubscription:
|
|
46
|
+
def test_subscribes_to_user_created(self) -> None:
|
|
47
|
+
plugin, _ = _make_plugin()
|
|
48
|
+
assert plugin.events.has_subscription("UserCreated")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class TestOnInstall:
|
|
52
|
+
async def test_seeds_default_widget(self) -> None:
|
|
53
|
+
plugin, fake = _make_plugin()
|
|
54
|
+
|
|
55
|
+
await plugin.seed_default_widget()
|
|
56
|
+
|
|
57
|
+
names = {r["name"] for r in fake.tables["widgets"]}
|
|
58
|
+
assert names == {_DEFAULT_WIDGET["name"]}
|
|
59
|
+
|
|
60
|
+
async def test_is_idempotent(self) -> None:
|
|
61
|
+
plugin, fake = _make_plugin()
|
|
62
|
+
|
|
63
|
+
await plugin.seed_default_widget()
|
|
64
|
+
await plugin.seed_default_widget()
|
|
65
|
+
|
|
66
|
+
assert len(fake.tables["widgets"]) == 1
|
|
67
|
+
|
|
68
|
+
async def test_default_widget_marked_active(self) -> None:
|
|
69
|
+
plugin, fake = _make_plugin()
|
|
70
|
+
|
|
71
|
+
await plugin.seed_default_widget()
|
|
72
|
+
|
|
73
|
+
widget = fake.tables["widgets"][0]
|
|
74
|
+
assert widget["is_active"] is True
|
|
75
|
+
|
|
76
|
+
def test_on_uninstall_is_a_noop(self) -> None:
|
|
77
|
+
plugin, _ = _make_plugin()
|
|
78
|
+
assert plugin.on_uninstall() is None
|
|
79
|
+
|
|
80
|
+
def test_on_install_sync_bridge_seeds_widget(self) -> None:
|
|
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."""
|
|
86
|
+
plugin, fake = _make_plugin()
|
|
87
|
+
|
|
88
|
+
plugin.on_install()
|
|
89
|
+
|
|
90
|
+
names = {r["name"] for r in fake.tables["widgets"]}
|
|
91
|
+
assert names == {_DEFAULT_WIDGET["name"]}
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class TestUserCreatedLogsEvent:
|
|
95
|
+
async def test_dispatch_does_not_raise(self) -> None:
|
|
96
|
+
"""The example handler only logs — this proves the subscription is
|
|
97
|
+
wired end to end (event -> EventSubscriber -> handler) without
|
|
98
|
+
raising, the same shape a real handler with side effects would be
|
|
99
|
+
tested in (see RBAC's test_rbac_plugin.py for a handler that
|
|
100
|
+
actually mutates state via self.api)."""
|
|
101
|
+
plugin, _ = _make_plugin()
|
|
102
|
+
|
|
103
|
+
event = BiffoEvent(
|
|
104
|
+
detail_type="UserCreated",
|
|
105
|
+
tenant_id="default",
|
|
106
|
+
payload={"cognito_sub": "user-123", "email": "new@example.com"},
|
|
107
|
+
)
|
|
108
|
+
await plugin.events.dispatch(event)
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# Biffo Plugin Manifest Schema Validator
|
|
2
|
+
|
|
3
|
+
Validates `biffo.plugin.json` against the registry schema.
|
|
4
|
+
Used by: CLI install flow, registry CI, SDK register_plugin().
|
|
5
|
+
|
|
6
|
+
## Usage
|
|
7
|
+
|
|
8
|
+
```bash
|
|
9
|
+
python -m jsonschema -i biffo.plugin.json registry-schema.json
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
Or programmatically:
|
|
13
|
+
|
|
14
|
+
```python
|
|
15
|
+
from jsonschema import validate, ValidationError
|
|
16
|
+
|
|
17
|
+
with open("registry-schema.json") as f:
|
|
18
|
+
schema = json.load(f)
|
|
19
|
+
|
|
20
|
+
with open("biffo.plugin.json") as f:
|
|
21
|
+
manifest = json.load(f)
|
|
22
|
+
|
|
23
|
+
try:
|
|
24
|
+
validate(instance=manifest, schema=schema)
|
|
25
|
+
except ValidationError as e:
|
|
26
|
+
print(f"Invalid manifest: {e.message}")
|
|
27
|
+
```
|