terp-cli 0.3.0__tar.gz → 0.5.0__tar.gz
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.
- terp_cli-0.5.0/PKG-INFO +16 -0
- {terp_cli-0.3.0 → terp_cli-0.5.0}/pyproject.toml +8 -8
- {terp_cli-0.3.0 → terp_cli-0.5.0}/src/terp/cli/__init__.py +404 -10
- terp_cli-0.5.0/src/terp/cli/capabilities.py +254 -0
- terp_cli-0.5.0/src/terp/cli/grants.py +233 -0
- terp_cli-0.5.0/src/terp/cli/service_accounts.py +79 -0
- {terp_cli-0.3.0 → terp_cli-0.5.0}/src/terp/cli/verify.py +77 -0
- terp_cli-0.3.0/PKG-INFO +0 -16
- {terp_cli-0.3.0 → terp_cli-0.5.0}/.gitignore +0 -0
- {terp_cli-0.3.0 → terp_cli-0.5.0}/src/terp/cli/_appref.py +0 -0
- {terp_cli-0.3.0 → terp_cli-0.5.0}/src/terp/cli/access.py +0 -0
- {terp_cli-0.3.0 → terp_cli-0.5.0}/src/terp/cli/apidocs.py +0 -0
- {terp_cli-0.3.0 → terp_cli-0.5.0}/src/terp/cli/dev.py +0 -0
- {terp_cli-0.3.0 → terp_cli-0.5.0}/src/terp/cli/docker.py +0 -0
- {terp_cli-0.3.0 → terp_cli-0.5.0}/src/terp/cli/jobs.py +0 -0
- {terp_cli-0.3.0 → terp_cli-0.5.0}/src/terp/cli/openapi.py +0 -0
- {terp_cli-0.3.0 → terp_cli-0.5.0}/src/terp/cli/profiles.py +0 -0
- {terp_cli-0.3.0 → terp_cli-0.5.0}/src/terp/cli/py.typed +0 -0
- {terp_cli-0.3.0 → terp_cli-0.5.0}/src/terp/cli/scaffold.py +0 -0
- {terp_cli-0.3.0 → terp_cli-0.5.0}/src/terp/cli/schema.py +0 -0
- {terp_cli-0.3.0 → terp_cli-0.5.0}/src/terp/cli/seed.py +0 -0
- {terp_cli-0.3.0 → terp_cli-0.5.0}/src/terp/cli/users.py +0 -0
terp_cli-0.5.0/PKG-INFO
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: terp-cli
|
|
3
|
+
Version: 0.5.0
|
|
4
|
+
Summary: Terp command-line tool — inspect, scaffolding, migrations, checks, api-docs.
|
|
5
|
+
License-Expression: Apache-2.0
|
|
6
|
+
Requires-Python: >=3.13
|
|
7
|
+
Requires-Dist: terp-arch==0.5.0
|
|
8
|
+
Requires-Dist: terp-core==0.5.0
|
|
9
|
+
Requires-Dist: terp-migrations==0.5.0
|
|
10
|
+
Provides-Extra: jobs
|
|
11
|
+
Requires-Dist: terp-cap-outbox==0.5.0; extra == 'jobs'
|
|
12
|
+
Requires-Dist: terp-cap-scheduler-apscheduler==0.5.0; extra == 'jobs'
|
|
13
|
+
Provides-Extra: scheduler
|
|
14
|
+
Requires-Dist: terp-cap-scheduler-apscheduler==0.5.0; extra == 'scheduler'
|
|
15
|
+
Provides-Extra: worker
|
|
16
|
+
Requires-Dist: terp-cap-outbox==0.5.0; extra == 'worker'
|
|
@@ -4,29 +4,29 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "terp-cli"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.5.0"
|
|
8
8
|
description = "Terp command-line tool — inspect, scaffolding, migrations, checks, api-docs."
|
|
9
9
|
requires-python = ">=3.13"
|
|
10
10
|
license = "Apache-2.0"
|
|
11
11
|
dependencies = [
|
|
12
|
-
"terp-core==0.
|
|
12
|
+
"terp-core==0.5.0",
|
|
13
13
|
# `terp migrate` delegates to terp-migrations (lazily imported), keeping Alembic
|
|
14
14
|
# off the path for `terp inspect` / `terp guide` while making migrations work.
|
|
15
|
-
"terp-migrations==0.
|
|
15
|
+
"terp-migrations==0.5.0",
|
|
16
16
|
# `terp guide rules` projects the live rule registry; terp-arch is imported lazily
|
|
17
17
|
# (only when that topic is rendered), so it stays off the common `terp guide` path.
|
|
18
|
-
"terp-arch==0.
|
|
18
|
+
"terp-arch==0.5.0",
|
|
19
19
|
]
|
|
20
20
|
|
|
21
21
|
# Job-process adapters stay optional: the base CLI must not install the table-owning outbox
|
|
22
22
|
# or a scheduler engine into apps that only use inspect/check/migrate. Deployments can select
|
|
23
23
|
# one process role, while `jobs` is the convenient complete jobs-process bundle.
|
|
24
24
|
[project.optional-dependencies]
|
|
25
|
-
worker = ["terp-cap-outbox==0.
|
|
26
|
-
scheduler = ["terp-cap-scheduler-apscheduler==0.
|
|
25
|
+
worker = ["terp-cap-outbox==0.5.0"]
|
|
26
|
+
scheduler = ["terp-cap-scheduler-apscheduler==0.5.0"]
|
|
27
27
|
jobs = [
|
|
28
|
-
"terp-cap-outbox==0.
|
|
29
|
-
"terp-cap-scheduler-apscheduler==0.
|
|
28
|
+
"terp-cap-outbox==0.5.0",
|
|
29
|
+
"terp-cap-scheduler-apscheduler==0.5.0",
|
|
30
30
|
]
|
|
31
31
|
|
|
32
32
|
[project.scripts]
|
|
@@ -18,6 +18,7 @@ from terp.cli.access import (
|
|
|
18
18
|
render_access_graph,
|
|
19
19
|
)
|
|
20
20
|
from terp.cli.apidocs import api_docs
|
|
21
|
+
from terp.cli.capabilities import render_capabilities
|
|
21
22
|
from terp.cli.dev import dev_plan, run_dev_command
|
|
22
23
|
from terp.cli.docker import run_docker_dev_command
|
|
23
24
|
from terp.cli.jobs import (
|
|
@@ -36,6 +37,12 @@ from terp.cli.schema import (
|
|
|
36
37
|
scan_declared_table_models,
|
|
37
38
|
)
|
|
38
39
|
from terp.cli.seed import run_seed_command
|
|
40
|
+
from terp.cli.grants import (
|
|
41
|
+
grant_add_command,
|
|
42
|
+
grant_list_command,
|
|
43
|
+
grant_revoke_command,
|
|
44
|
+
)
|
|
45
|
+
from terp.cli.service_accounts import create_service_account_command
|
|
39
46
|
from terp.cli.users import create_user_command
|
|
40
47
|
from terp.cli.verify import profile_ids, run_verify_command, verify_manifest
|
|
41
48
|
|
|
@@ -74,6 +81,49 @@ Add a module (the "10-minute module")
|
|
|
74
81
|
module = ModuleSpec(name="invoices", router=router, policy=Policy.default())
|
|
75
82
|
|
|
76
83
|
Then run `terp check`. Policy.default() = authenticated; read VIEWER, write EDITOR.
|
|
84
|
+
""",
|
|
85
|
+
"dependencies": """\
|
|
86
|
+
One module needs another (declared edges)
|
|
87
|
+
|
|
88
|
+
Modules are independent by default: an import of a sibling is refused. When a real
|
|
89
|
+
dependency exists, DECLARE it — do not hand-roll dependency inversion.
|
|
90
|
+
|
|
91
|
+
1) Name the sibling in the DEPENDING module's manifest:
|
|
92
|
+
# app/modules/catalogs/module.py
|
|
93
|
+
module = ModuleSpec(
|
|
94
|
+
name="catalogs",
|
|
95
|
+
router=router,
|
|
96
|
+
policy=Policy.default(),
|
|
97
|
+
requires=("connections",), # <- the edge, in the manifest a reader consults
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
2) Now import its published surface:
|
|
101
|
+
from app.modules.connections.service import ConnectionProfileService
|
|
102
|
+
|
|
103
|
+
Three limits, each enforced:
|
|
104
|
+
|
|
105
|
+
- DECLARED. An undeclared sibling import fails `no_cross_module_imports`. The point
|
|
106
|
+
is not to make coupling hard, it is to make coupling VISIBLE.
|
|
107
|
+
- PUBLIC SURFACE ONLY. An edge grants models / schemas / service / events. Never the
|
|
108
|
+
dependency's router (that couples two modules through HTTP shapes and lets an
|
|
109
|
+
in-process call walk past the policy guarding those routes) and never an
|
|
110
|
+
underscore-prefixed internal. Import a named surface, not the bare package.
|
|
111
|
+
- ONE-WAY. The declared graph must be acyclic — `terp check` reports the cycle and
|
|
112
|
+
the app refuses to boot on one. A cycle means the two modules are really one.
|
|
113
|
+
|
|
114
|
+
When NOT to declare an edge:
|
|
115
|
+
|
|
116
|
+
- You only need to REACT to the other module -> subscribe to its event
|
|
117
|
+
(`terp guide events`). An event keeps the direction one-way and the coupling loose.
|
|
118
|
+
- BOTH directions want an edge -> the shared concept belongs in a third module that
|
|
119
|
+
both depend on. Inverting one direction by hand (a Protocol plus a global registry
|
|
120
|
+
plus a composition-root adapter) is the same coupling with more moving parts and
|
|
121
|
+
module-global mutable state; declare the edge or extract the concept.
|
|
122
|
+
- The dependency is a capability (audit, files, users) -> that is not a module edge.
|
|
123
|
+
`requires=("audit",)` still means "must be installed"; capabilities are importable
|
|
124
|
+
by every module already.
|
|
125
|
+
|
|
126
|
+
Check it: `terp check` | See the declared edges: `terp inspect control-plane`
|
|
77
127
|
""",
|
|
78
128
|
"service": """\
|
|
79
129
|
Services (BaseService)
|
|
@@ -86,12 +136,21 @@ Services (BaseService)
|
|
|
86
136
|
conditions and CANNOT drop soft-delete / tenant scope:
|
|
87
137
|
def business_filters(self):
|
|
88
138
|
return (Invoice.status == "open",)
|
|
89
|
-
- A per-call filter
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
139
|
+
- A per-call filter/sort (a query parameter) is DECLARED, not hand-written. Name the
|
|
140
|
+
allowed columns on the service and pass the values through — an undeclared field is
|
|
141
|
+
refused (400), and the caller sends values, never operators:
|
|
142
|
+
class InvoiceService(BaseService[Invoice, InvoiceCreate, InvoiceUpdate]):
|
|
143
|
+
model = Invoice
|
|
144
|
+
filterable = (FilterField("status", Invoice.status),
|
|
145
|
+
FilterField("q", Invoice.title, op="contains"))
|
|
146
|
+
sortable = (SortField("due_on", Invoice.due_on),)
|
|
147
|
+
default_sort = ("-due_on",)
|
|
148
|
+
# route: status: str | None = None -> typed, and it shows up in OpenAPI
|
|
149
|
+
rows, total = service.list(session, skip=..., limit=..., filters={"status": status},
|
|
150
|
+
sort=sort)
|
|
151
|
+
None values are dropped, so an absent optional parameter needs no branching. Filters
|
|
152
|
+
compose ON TOP of base_query(), so row scope and business_filters() still apply —
|
|
153
|
+
they cannot widen a read. Don't write a list() override to do this by hand.
|
|
95
154
|
- On a large table, prefer keyset pagination: a route takes CursorPaginationDep and
|
|
96
155
|
returns CursorPage[ReadDTO] from self.list_by_cursor(session, pagination=...) —
|
|
97
156
|
no OFFSET scan, and the exact COUNT runs only when the caller asks
|
|
@@ -128,6 +187,56 @@ Authorization (Policy)
|
|
|
128
187
|
min_role floor AND hold the grant.
|
|
129
188
|
- Route-level extra check: dependencies=[Depends(require_permission("invoices.approve"))].
|
|
130
189
|
- Authority is always a typed object (Role / Permission), never a bare string.
|
|
190
|
+
- Choosing between a role and a permission, and the grant lifecycle: `terp guide permissions`.
|
|
191
|
+
""",
|
|
192
|
+
"permissions": """\
|
|
193
|
+
Permissions — when to add one, and how it reaches a subject
|
|
194
|
+
|
|
195
|
+
WHEN A PERMISSION IS THE RIGHT ANSWER
|
|
196
|
+
- The role ladder answers "how much of this app may you touch?" - VIEWER < EDITOR
|
|
197
|
+
< ADMIN, coarse and comparable. A permission answers "may you do this ONE thing?"
|
|
198
|
+
and is not comparable to anything.
|
|
199
|
+
- Add a permission when the authority is NARROWER than a role and does not belong
|
|
200
|
+
on the ladder: an integration that may export invoices but must not edit them; a
|
|
201
|
+
finance user who may approve but is not an app admin.
|
|
202
|
+
- Do NOT add a permission for something every EDITOR should be able to do - that is
|
|
203
|
+
the role, and a permission held by everyone is a checkbox nobody reads.
|
|
204
|
+
- Do NOT model a role as three permissions. The ladder exists so authority stays
|
|
205
|
+
comparable; a bag of permissions cannot answer "is this account privileged?".
|
|
206
|
+
|
|
207
|
+
DECLARE IT
|
|
208
|
+
from terp.core import EDITOR, Permission
|
|
209
|
+
APPROVE = Permission("invoices.approve", min_role=EDITOR)
|
|
210
|
+
min_role is a FLOOR, not a shortcut: the caller must clear it AND hold the grant.
|
|
211
|
+
Both checks are real - the floor stops a grant from smuggling authority to a
|
|
212
|
+
subject the app never intended to trust that far.
|
|
213
|
+
- Enforce it on the module (Policy(write=APPROVE)) or on one route
|
|
214
|
+
(dependencies=[Depends(require_permission("invoices.approve"))]).
|
|
215
|
+
- Wire the enforcer once, or the app refuses to boot (fail closed - a Permission
|
|
216
|
+
requirement with nothing to enforce it would silently pass):
|
|
217
|
+
create_app(..., permission_enforcer=terp.capabilities.access.enforce_permission)
|
|
218
|
+
|
|
219
|
+
GRANT IT
|
|
220
|
+
terp grant add ops@acme.test invoices.approve
|
|
221
|
+
terp grant add nightly-sync invoices.export # a service account, by name
|
|
222
|
+
terp grant list ops@acme.test # "why can it do that?"
|
|
223
|
+
terp grant revoke ops@acme.test invoices.approve
|
|
224
|
+
A subject is named the way you name it - a user email, a service-account name, or
|
|
225
|
+
a subject UUID. An unknown permission is refused WITH the app's catalog printed,
|
|
226
|
+
because a grant of a string the app never checks is a silent no-op, not a lenient
|
|
227
|
+
grant. Grants are stored per subject id with no foreign key, which is what lets a
|
|
228
|
+
user, a service account and a group all be granted the same way; a group grant
|
|
229
|
+
reaches every member (`terp guide access`).
|
|
230
|
+
- Mind the floor: granting a permission to a subject BELOW its min_role stores a row
|
|
231
|
+
that the rank check will shadow. `terp grant add` warns when it sees this.
|
|
232
|
+
- See the whole declared catalog and every module's policy:
|
|
233
|
+
terp inspect control-plane --app app.main:build
|
|
234
|
+
|
|
235
|
+
THE FAILURE MODE THIS EXISTS TO PREVENT
|
|
236
|
+
An integration needs one narrow capability, granting looks like work, so someone
|
|
237
|
+
makes it an admin "for now". Least privilege loses to a ten-second workaround. If
|
|
238
|
+
you catch yourself widening a role to unblock one call, that call wants a
|
|
239
|
+
permission - and the grant is one command (ADR 0089).
|
|
131
240
|
""",
|
|
132
241
|
"access": """\
|
|
133
242
|
The access model (three layers) — profiles + the access graph
|
|
@@ -160,6 +269,8 @@ The access model (three layers) — profiles + the access graph
|
|
|
160
269
|
write authority, and warnings (e.g. OwnedMixin gates writes only). `--format json`
|
|
161
270
|
is the stable Studio contract; declare services=(InvoiceService,) on the ModuleSpec
|
|
162
271
|
so the data layer is visualizable — an undeclared data layer is a warning.
|
|
272
|
+
- Narrowing authority below a role, and getting a permission to a subject:
|
|
273
|
+
`terp guide permissions` (declare it, enforce it, `terp grant add`).
|
|
163
274
|
""",
|
|
164
275
|
"ownership": """\
|
|
165
276
|
Object-level (per-row) authorization (OwnedMixin)
|
|
@@ -279,6 +390,103 @@ Background jobs (terp.core.enqueue + JobCatalog)
|
|
|
279
390
|
against the JobCatalog. Run schedules in-process with `terp jobs scheduler` (APScheduler;
|
|
280
391
|
needs terp-cap-scheduler-apscheduler) or via Celery beat — each cron tick enqueues through
|
|
281
392
|
the same typed seam, so a scheduled job stays audited + system-actor stamped.
|
|
393
|
+
""",
|
|
394
|
+
"outbox": """\
|
|
395
|
+
Durable post-commit delivery (outbox capability)
|
|
396
|
+
|
|
397
|
+
- The problem it solves: an in-process dispatcher runs the side effect AFTER the commit,
|
|
398
|
+
outside the transaction - so a crash between the two loses it, and a rollback after it
|
|
399
|
+
already fired published a lie. The outbox writes the INTENT to a table in the SAME
|
|
400
|
+
transaction as the row, and a separate worker delivers it afterwards.
|
|
401
|
+
- terp-cap-outbox is a LIBRARY capability: it ships no router and nothing auto-mounts.
|
|
402
|
+
You swap the two seams at the composition root:
|
|
403
|
+
create_app(specs, ..., job_queue=OutboxJobQueue(),
|
|
404
|
+
event_dispatcher=outbox_event_dispatcher)
|
|
405
|
+
It ships its own migrations, so add the dependency and run `terp migrate` before
|
|
406
|
+
switching the seams over.
|
|
407
|
+
- Nothing in a module changes. You still enqueue(session, job=..., payload=...) and
|
|
408
|
+
still emit events through the catalog; the seam is swapped once, centrally, so a
|
|
409
|
+
module never knows whether delivery is in-process or durable.
|
|
410
|
+
- A custom side effect joins the same atomic unit through the service's _after_write
|
|
411
|
+
hook (which runs inside the write transaction, before the commit):
|
|
412
|
+
def _after_write(self, session, entity, *, action):
|
|
413
|
+
enqueue(session, job=RUN_START, payload=RunStartPayload(run_id=entity.id))
|
|
414
|
+
- Run the relay as its own process (not inside the API container):
|
|
415
|
+
terp jobs worker
|
|
416
|
+
It leases rows (claim_id + locked_until, SKIP LOCKED), delivers AT LEAST ONCE, retries
|
|
417
|
+
with exponential backoff, and dead-letters once max_attempts is exhausted. Every
|
|
418
|
+
handler must therefore be safe to run twice - see `terp guide idempotency`.
|
|
419
|
+
- In production, refuse to boot on the in-process default:
|
|
420
|
+
create_app(..., job_queue=OutboxJobQueue(),
|
|
421
|
+
require_durable_jobs=get_settings().is_production)
|
|
422
|
+
""",
|
|
423
|
+
"idempotency": """\
|
|
424
|
+
Idempotency (the Idempotency-Key header, terp.core.idempotency)
|
|
425
|
+
|
|
426
|
+
- Already wired: create_app installs the idempotency middleware for every unsafe method
|
|
427
|
+
(POST/PUT/PATCH/DELETE). There is nothing to decorate and no per-route opt-in - the
|
|
428
|
+
CLIENT opts in per request by sending a header:
|
|
429
|
+
Idempotency-Key: <client-generated unique key>
|
|
430
|
+
A request without the header behaves exactly as it did before.
|
|
431
|
+
- The semantics, all typed envelopes:
|
|
432
|
+
first call -> executes; the response is stored under the key
|
|
433
|
+
retry, same body -> the stored response is replayed
|
|
434
|
+
(response header idempotency-replayed: true)
|
|
435
|
+
same key, different body -> 422 idempotency_key_mismatch
|
|
436
|
+
still executing -> 409 idempotency_in_flight (Retry-After: 1)
|
|
437
|
+
malformed key -> 400 invalid_idempotency_key
|
|
438
|
+
store unavailable -> 503 idempotency_unavailable (fail closed - never a
|
|
439
|
+
second write)
|
|
440
|
+
- Use it wherever a retry must not create a second row: starting a run, submitting a
|
|
441
|
+
payment, POSTing a snapshot from a worker that retries on a network error. Document
|
|
442
|
+
the header on those routes so the caller actually sends one.
|
|
443
|
+
- The default InMemoryIdempotencyStore is PER PROCESS - correct for dev and a single
|
|
444
|
+
replica, useless across replicas. A multi-replica deployment needs a shared store, and
|
|
445
|
+
should refuse to boot without one:
|
|
446
|
+
create_app(..., idempotency_store=RedisIdempotencyStore(...),
|
|
447
|
+
require_shared_idempotency_store=get_settings().is_production)
|
|
448
|
+
Only a store that marks itself shared (mark_shared_idempotency_store) satisfies the
|
|
449
|
+
guard, so a load-balanced deployment cannot silently lose the guarantee.
|
|
450
|
+
- Background work carries its own key: enqueue(..., idempotency_key="customers-2026-06-29").
|
|
451
|
+
Job delivery is at-least-once, so a handler must ALSO be safe to run twice on its own -
|
|
452
|
+
a natural unique constraint in the database is the strongest form of that.
|
|
453
|
+
""",
|
|
454
|
+
"realtime": """\
|
|
455
|
+
Realtime push (realtime capability)
|
|
456
|
+
|
|
457
|
+
- Install terp-cap-realtime; it is a routed capability, so discovery mounts it:
|
|
458
|
+
create_app(specs, discover_capabilities=True,
|
|
459
|
+
capability_names=(..., "realtime"))
|
|
460
|
+
- Declare every channel ONCE at import time, with a typed outbound model and the
|
|
461
|
+
authorization it demands - never a bare topic string:
|
|
462
|
+
class RunProgress(BaseModel):
|
|
463
|
+
run_id: uuid.UUID
|
|
464
|
+
state: str = Field(max_length=32)
|
|
465
|
+
done: int = Field(ge=0)
|
|
466
|
+
RUN_PROGRESS = register_channel(
|
|
467
|
+
RealtimeChannel("runs.progress", RunProgress, requirement=Roles.VIEWER))
|
|
468
|
+
RealtimeChannel(name, outbound_model, mode="sse"|"websocket", requirement=...,
|
|
469
|
+
inbound_requirement=..., inbound_model=..., on_message=...,
|
|
470
|
+
audience=principal_audience | global_audience). The default audience is
|
|
471
|
+
the principal, so a message reaches only the user you address it to.
|
|
472
|
+
- Publish from a service, an _after_write hook, or a job handler; the payload is
|
|
473
|
+
validated against the channel's outbound_model:
|
|
474
|
+
await publish(RUN_PROGRESS, RunProgress(...), audience=str(principal.id))
|
|
475
|
+
- Wire the runtime seams once at the composition root:
|
|
476
|
+
configure_realtime(permission_enforcer=..., principal_validator=...,
|
|
477
|
+
message_session_provider=...)
|
|
478
|
+
configure_broker / configure_ticket_store replace the in-memory defaults when you run
|
|
479
|
+
more than one replica (the in-memory ones are per process).
|
|
480
|
+
- Transport is TICKET-based, never a token in a URL: the client POSTs
|
|
481
|
+
/api/v1/realtime/tickets, receives a one-use short-lived ticket, then connects to
|
|
482
|
+
/api/v1/realtime/sse/<channel>?ticket=... (or /ws/<channel> in websocket mode). The
|
|
483
|
+
channel's requirement is enforced when the ticket is minted.
|
|
484
|
+
- Frontend: useRealtimeChannel({ channel: "runs.progress", validate }) from
|
|
485
|
+
@terpjs/react-core performs the whole dance. Never hand-roll EventSource or WebSocket -
|
|
486
|
+
the boundary lint refuses both.
|
|
487
|
+
- Inbound (websocket) messages are size-capped, validated against inbound_model, gated by
|
|
488
|
+
inbound_requirement, and handled by on_message with a real session - so a client message
|
|
489
|
+
goes through the same audited service path as an HTTP write.
|
|
282
490
|
""",
|
|
283
491
|
"files": """\
|
|
284
492
|
File objects (files capability, ADR 0056/0057)
|
|
@@ -325,6 +533,12 @@ Using capabilities
|
|
|
325
533
|
|
|
326
534
|
- Capabilities are opt-in packages (terp-cap-*); the base profile is auth + access +
|
|
327
535
|
identity + users (+ projects). Install the ones you need.
|
|
536
|
+
- SEE WHAT EXISTS BEFORE YOU BUILD IT:
|
|
537
|
+
terp inspect capabilities
|
|
538
|
+
lists every maintained capability, whether this app already has it, the exact
|
|
539
|
+
`uv add` line and the composition-root wiring it expects. Durable delivery, realtime
|
|
540
|
+
push, tenancy, files, webhooks, scheduling and shared multi-replica state are all
|
|
541
|
+
already solved — hand-rolling one of them is a defect, not a shortcut.
|
|
328
542
|
- A routed capability self-registers: create_app(specs, discover_capabilities=True)
|
|
329
543
|
mounts it at /api/v1/<name> via its entry point — no composition-root edit.
|
|
330
544
|
- A library capability (tenancy, eventbus) ships no router; you import and wire it
|
|
@@ -372,6 +586,29 @@ Database migrations (terp migrate)
|
|
|
372
586
|
- Destructive DDL (drop table/column or alter-column type changes) is refused by
|
|
373
587
|
`terp check` unless the operation carries `# arch-allow-no-destructive-migrations:
|
|
374
588
|
<reason>` on (or immediately above) its line, budgeted by the escape-hatch ratchet.
|
|
589
|
+
- MOVING A MODEL TO ANOTHER MODULE is the one edit that looks free and is not. Just
|
|
590
|
+
moving the class emits NO ddl at all - the losing package stops owning the table so
|
|
591
|
+
its scoped autogenerate cannot propose a drop, and the gaining package diffs against
|
|
592
|
+
a database where the table already exists so it proposes no create. Your database
|
|
593
|
+
keeps upgrading; CI and staging stay green. Then the NEXT ordinary change to that
|
|
594
|
+
model (an added column) is authored into the gaining package's INDEPENDENT history,
|
|
595
|
+
which - with no foreign key between the two packages - a fresh install may run
|
|
596
|
+
BEFORE the history that creates the table: "no such table", months later, in a new
|
|
597
|
+
environment, blamed on an innocent add_column. Terp refuses the split at `terp
|
|
598
|
+
migrate make` and in the build-time guard. Move a table properly, expand/contract:
|
|
599
|
+
1. Give the new module its own model with its own __tablename__;
|
|
600
|
+
`terp migrate make <new>` creates it.
|
|
601
|
+
2. Copy the rows in that same release (a data migration, or a backfill job),
|
|
602
|
+
and write to both tables while the old one still has readers.
|
|
603
|
+
3. In a LATER release, once nothing reads the old table, drop it - with
|
|
604
|
+
`# arch-allow-no-destructive-migrations: <reason>` and a human reviewer.
|
|
605
|
+
Step 3 stays human-reviewed on purpose: dropping a populated table is a genuine,
|
|
606
|
+
irreversible risk, and the one place where a second pair of eyes is worth the
|
|
607
|
+
friction. If the model is simply in the wrong module and holds no data you care
|
|
608
|
+
about, the cheap fix is to move the class BACK and rename the module instead.
|
|
609
|
+
Already shipped the move and planning the expand/contract for a later release? The
|
|
610
|
+
owning package's models.py may carry `# arch-allow-table-ownership-is-not-split:
|
|
611
|
+
<reason>`, counted by the escape-hatch budget like any other opt-out.
|
|
375
612
|
- Adopt Terp on an EXISTING database (built by create_all or by hand) without dropping
|
|
376
613
|
data - baseline each history at head, then only genuinely new migrations apply:
|
|
377
614
|
terp migrate stamp # records head, runs no DDL
|
|
@@ -594,6 +831,7 @@ Golden rules (the gate enforces these — follow them and it stays green):
|
|
|
594
831
|
More: terp guide <topic> (topics: {_TOPIC_NAMES})
|
|
595
832
|
terp guide <rule> (the exact rule's remediation and related pattern)
|
|
596
833
|
terp guide rules (every architecture rule the gate enforces, generated)
|
|
834
|
+
terp inspect capabilities (what the platform offers: installed vs adoptable)
|
|
597
835
|
terp inspect control-plane (your roles / permissions / module authority map)
|
|
598
836
|
terp inspect access (the full access graph: modules, endpoints, data traits)
|
|
599
837
|
terp check (run the full architecture gate locally)
|
|
@@ -683,6 +921,25 @@ def guide(topic: str | None = None) -> str:
|
|
|
683
921
|
return _render_rule_guide(topic)
|
|
684
922
|
|
|
685
923
|
|
|
924
|
+
def gate_root(root: str | pathlib.Path = ".", *, package: str = "app") -> pathlib.Path:
|
|
925
|
+
"""Resolve the directory the architecture gate scans, from a CLI ``--root``.
|
|
926
|
+
|
|
927
|
+
The gate's unit of scope is the **app package** — the test harness calls
|
|
928
|
+
``assert_app_clean("app")``, so ``app/`` is what the rules walk. A CLI ``--root``,
|
|
929
|
+
though, is naturally the *project* root (that is where every other ``terp``
|
|
930
|
+
command runs, and the default is ``.``), which also contains code the gate does
|
|
931
|
+
not govern: a standalone ``engine/``, ``conformance/``, tooling scripts. Scanning
|
|
932
|
+
that made the two entry points to the same gate disagree about scope.
|
|
933
|
+
|
|
934
|
+
So ``--root`` accepts either: if it contains the app package, the package dir is
|
|
935
|
+
the scan root; otherwise *root* is already the package dir and is used as-is.
|
|
936
|
+
Both spellings then hold the app to exactly the scope ``uv run pytest`` does.
|
|
937
|
+
"""
|
|
938
|
+
path = pathlib.Path(root)
|
|
939
|
+
candidate = path / package
|
|
940
|
+
return candidate if candidate.is_dir() else path
|
|
941
|
+
|
|
942
|
+
|
|
686
943
|
def check_report(
|
|
687
944
|
root: str = ".", *, package: str = "app", budget_path: str | None = None
|
|
688
945
|
) -> dict[str, object]:
|
|
@@ -706,9 +963,10 @@ def check_report(
|
|
|
706
963
|
from terp.arch import check_app, guide_topic_for, ungoverned_marker_violations
|
|
707
964
|
from terp.arch.rules import GUIDE_TOPIC_BY_RULE
|
|
708
965
|
|
|
709
|
-
|
|
966
|
+
scan_root = gate_root(root, package=package)
|
|
967
|
+
violations = list(check_app(scan_root, package=package, budget_path=budget_path))
|
|
710
968
|
if budget_path is None:
|
|
711
|
-
violations.extend(ungoverned_marker_violations(
|
|
969
|
+
violations.extend(ungoverned_marker_violations(scan_root, package=package))
|
|
712
970
|
violations.sort(key=lambda violation: (violation.path, violation.line, violation.rule))
|
|
713
971
|
rules = set(GUIDE_TOPIC_BY_RULE)
|
|
714
972
|
if budget_path is None:
|
|
@@ -976,6 +1234,7 @@ def _module_json(spec: ModuleSpec) -> dict[str, object]:
|
|
|
976
1234
|
return {
|
|
977
1235
|
"name": spec.name,
|
|
978
1236
|
"policy": policy,
|
|
1237
|
+
"requires": list(spec.requires),
|
|
979
1238
|
"emits": [event.name for event in spec.emits],
|
|
980
1239
|
"subscribes": [event.name for event in spec.subscribes],
|
|
981
1240
|
"jobs": [job.name for job in spec.jobs],
|
|
@@ -998,6 +1257,8 @@ def _render_text(plane: ControlPlane, specs: Sequence[ModuleSpec]) -> str:
|
|
|
998
1257
|
lines.append(" <none provided>")
|
|
999
1258
|
for spec in sorted(specs, key=lambda item: item.name):
|
|
1000
1259
|
lines.append(f" {spec.name} {_policy_label(spec)}")
|
|
1260
|
+
if spec.requires:
|
|
1261
|
+
lines.append(f" requires: {', '.join(sorted(spec.requires))}")
|
|
1001
1262
|
lines.append("")
|
|
1002
1263
|
lines.append("Audit")
|
|
1003
1264
|
if plane.audit.enabled:
|
|
@@ -1166,6 +1427,17 @@ def _build_parser() -> argparse.ArgumentParser:
|
|
|
1166
1427
|
default="text",
|
|
1167
1428
|
help="Output format: text (human) or json (structured, for Studio; default: text)",
|
|
1168
1429
|
)
|
|
1430
|
+
capabilities_parser = inspect_subcommands.add_parser(
|
|
1431
|
+
"capabilities",
|
|
1432
|
+
help="Every maintained terp-cap-* package: installed here vs available to "
|
|
1433
|
+
"adopt, with the `uv add` line and its composition-root wiring",
|
|
1434
|
+
)
|
|
1435
|
+
capabilities_parser.add_argument(
|
|
1436
|
+
"--format",
|
|
1437
|
+
choices=("text", "json"),
|
|
1438
|
+
default="text",
|
|
1439
|
+
help="Output format: text (human) or json (structured, for Studio; default: text)",
|
|
1440
|
+
)
|
|
1169
1441
|
schema_parser = inspect_subcommands.add_parser(
|
|
1170
1442
|
"schema",
|
|
1171
1443
|
help="The schema graph: every table with ownership, traits, and fail-visible "
|
|
@@ -1349,7 +1621,12 @@ def _build_parser() -> argparse.ArgumentParser:
|
|
|
1349
1621
|
)
|
|
1350
1622
|
|
|
1351
1623
|
check_parser = subcommands.add_parser("check", help="Run the architecture gate locally")
|
|
1352
|
-
check_parser.add_argument(
|
|
1624
|
+
check_parser.add_argument(
|
|
1625
|
+
"--root",
|
|
1626
|
+
default=".",
|
|
1627
|
+
help="Project root, or the app package itself; the gate scans the app package "
|
|
1628
|
+
"(default: .)",
|
|
1629
|
+
)
|
|
1353
1630
|
check_parser.add_argument("--package", default="app", help="App package (default: app)")
|
|
1354
1631
|
check_parser.add_argument(
|
|
1355
1632
|
"--budget", default=None, help="Escape-hatch budget JSON (governs # arch-allow markers)"
|
|
@@ -1423,6 +1700,78 @@ def _build_parser() -> argparse.ArgumentParser:
|
|
|
1423
1700
|
help="Env var holding the new password; prompts if unset (default: TERP_USER_PASSWORD)",
|
|
1424
1701
|
)
|
|
1425
1702
|
|
|
1703
|
+
sa_parser = subcommands.add_parser(
|
|
1704
|
+
"service-account",
|
|
1705
|
+
help="Manage machine credentials (so an integration never needs a person's login)",
|
|
1706
|
+
)
|
|
1707
|
+
sa_subcommands = sa_parser.add_subparsers(dest="service_account_command", required=True)
|
|
1708
|
+
sa_create_parser = sa_subcommands.add_parser(
|
|
1709
|
+
"create", help="Provision a service account and print its credentials once"
|
|
1710
|
+
)
|
|
1711
|
+
sa_create_parser.add_argument("name", help="Human-readable name of the integration")
|
|
1712
|
+
sa_create_parser.add_argument(
|
|
1713
|
+
"--role",
|
|
1714
|
+
required=True,
|
|
1715
|
+
help="viewer / editor / admin or an integer rank (required: an integration's "
|
|
1716
|
+
"authority is chosen, never inherited)",
|
|
1717
|
+
)
|
|
1718
|
+
sa_create_parser.add_argument(
|
|
1719
|
+
"--description", default=None, help="What this credential is for, and who owns it"
|
|
1720
|
+
)
|
|
1721
|
+
sa_create_parser.add_argument(
|
|
1722
|
+
"--expires-in-days",
|
|
1723
|
+
type=int,
|
|
1724
|
+
default=365,
|
|
1725
|
+
help="Days until the credential lapses; 0 means never (default: 365)",
|
|
1726
|
+
)
|
|
1727
|
+
sa_create_parser.add_argument(
|
|
1728
|
+
"--app",
|
|
1729
|
+
default="app.main:app",
|
|
1730
|
+
help="Dotted module:attribute of the FastAPI app (default: app.main:app)",
|
|
1731
|
+
)
|
|
1732
|
+
sa_create_parser.add_argument(
|
|
1733
|
+
"--app-root", default=".", help="App root placed first on sys.path (default: .)"
|
|
1734
|
+
)
|
|
1735
|
+
|
|
1736
|
+
grant_parser = subcommands.add_parser(
|
|
1737
|
+
"grant",
|
|
1738
|
+
help="Assign, list and revoke a subject's permission grants (least privilege "
|
|
1739
|
+
"without hand-writing an admin API call)",
|
|
1740
|
+
)
|
|
1741
|
+
grant_subcommands = grant_parser.add_subparsers(dest="grant_command", required=True)
|
|
1742
|
+
|
|
1743
|
+
_SUBJECT_HELP = (
|
|
1744
|
+
"A user email, a service-account name, or a subject UUID"
|
|
1745
|
+
)
|
|
1746
|
+
for _name, _help in (
|
|
1747
|
+
("add", "Grant a permission to a subject (idempotent, audited)"),
|
|
1748
|
+
("revoke", "Revoke a permission from a subject"),
|
|
1749
|
+
):
|
|
1750
|
+
_p = grant_subcommands.add_parser(_name, help=_help)
|
|
1751
|
+
_p.add_argument("subject", help=_SUBJECT_HELP)
|
|
1752
|
+
_p.add_argument("permission", help="The permission name the app declares")
|
|
1753
|
+
_p.add_argument(
|
|
1754
|
+
"--app",
|
|
1755
|
+
default="app.main:app",
|
|
1756
|
+
help="Dotted module:attribute of the FastAPI app (default: app.main:app)",
|
|
1757
|
+
)
|
|
1758
|
+
_p.add_argument(
|
|
1759
|
+
"--app-root", default=".", help="App root placed first on sys.path (default: .)"
|
|
1760
|
+
)
|
|
1761
|
+
|
|
1762
|
+
grant_list_parser = grant_subcommands.add_parser(
|
|
1763
|
+
"list", help="List every permission a subject holds, including via groups"
|
|
1764
|
+
)
|
|
1765
|
+
grant_list_parser.add_argument("subject", help=_SUBJECT_HELP)
|
|
1766
|
+
grant_list_parser.add_argument(
|
|
1767
|
+
"--app",
|
|
1768
|
+
default="app.main:app",
|
|
1769
|
+
help="Dotted module:attribute of the FastAPI app (default: app.main:app)",
|
|
1770
|
+
)
|
|
1771
|
+
grant_list_parser.add_argument(
|
|
1772
|
+
"--app-root", default=".", help="App root placed first on sys.path (default: .)"
|
|
1773
|
+
)
|
|
1774
|
+
|
|
1426
1775
|
seed_parser = subcommands.add_parser(
|
|
1427
1776
|
"seed", help="Run the app's seed routine (idempotent demo / bootstrap data; dev only)"
|
|
1428
1777
|
)
|
|
@@ -1485,6 +1834,9 @@ def main(argv: Sequence[str] | None = None) -> None:
|
|
|
1485
1834
|
if args.command == "inspect" and args.inspect_command == "schema":
|
|
1486
1835
|
print(inspect_schema(app_root=args.app_root, package=args.package, fmt=args.format))
|
|
1487
1836
|
return
|
|
1837
|
+
if args.command == "inspect" and args.inspect_command == "capabilities":
|
|
1838
|
+
print(render_capabilities(fmt=args.format))
|
|
1839
|
+
return
|
|
1488
1840
|
if args.command == "guide":
|
|
1489
1841
|
if args.topic is not None and args.topic not in guide_choices():
|
|
1490
1842
|
raise SystemExit(
|
|
@@ -1569,7 +1921,11 @@ def main(argv: Sequence[str] | None = None) -> None:
|
|
|
1569
1921
|
return
|
|
1570
1922
|
from terp.arch import assert_app_clean
|
|
1571
1923
|
|
|
1572
|
-
assert_app_clean(
|
|
1924
|
+
assert_app_clean(
|
|
1925
|
+
gate_root(args.root, package=args.package),
|
|
1926
|
+
package=args.package,
|
|
1927
|
+
budget_path=args.budget,
|
|
1928
|
+
)
|
|
1573
1929
|
print("terp.arch: app is clean")
|
|
1574
1930
|
return
|
|
1575
1931
|
if args.command == "verify":
|
|
@@ -1593,6 +1949,39 @@ def main(argv: Sequence[str] | None = None) -> None:
|
|
|
1593
1949
|
)
|
|
1594
1950
|
)
|
|
1595
1951
|
return
|
|
1952
|
+
if args.command == "service-account" and args.service_account_command == "create":
|
|
1953
|
+
print(
|
|
1954
|
+
create_service_account_command(
|
|
1955
|
+
args.name,
|
|
1956
|
+
role=args.role,
|
|
1957
|
+
description=args.description,
|
|
1958
|
+
expires_in_days=args.expires_in_days or None,
|
|
1959
|
+
app_ref=args.app,
|
|
1960
|
+
app_root=args.app_root,
|
|
1961
|
+
)
|
|
1962
|
+
)
|
|
1963
|
+
return
|
|
1964
|
+
if args.command == "grant":
|
|
1965
|
+
_commands = {
|
|
1966
|
+
"add": grant_add_command,
|
|
1967
|
+
"revoke": grant_revoke_command,
|
|
1968
|
+
}
|
|
1969
|
+
if args.grant_command == "list":
|
|
1970
|
+
print(
|
|
1971
|
+
grant_list_command(
|
|
1972
|
+
args.subject, app_ref=args.app, app_root=args.app_root
|
|
1973
|
+
)
|
|
1974
|
+
)
|
|
1975
|
+
return
|
|
1976
|
+
print(
|
|
1977
|
+
_commands[args.grant_command](
|
|
1978
|
+
args.subject,
|
|
1979
|
+
args.permission,
|
|
1980
|
+
app_ref=args.app,
|
|
1981
|
+
app_root=args.app_root,
|
|
1982
|
+
)
|
|
1983
|
+
)
|
|
1984
|
+
return
|
|
1596
1985
|
if args.command == "seed":
|
|
1597
1986
|
print(run_seed_command(app_ref=args.app, app_root=args.app_root, seed_ref=args.seed))
|
|
1598
1987
|
return
|
|
@@ -1610,9 +1999,14 @@ __all__ = [
|
|
|
1610
1999
|
"api_docs",
|
|
1611
2000
|
"check_report",
|
|
1612
2001
|
"check_report_envelope",
|
|
2002
|
+
"create_service_account_command",
|
|
1613
2003
|
"create_user_command",
|
|
2004
|
+
"grant_add_command",
|
|
2005
|
+
"grant_list_command",
|
|
2006
|
+
"grant_revoke_command",
|
|
1614
2007
|
"dev_plan",
|
|
1615
2008
|
"export_openapi",
|
|
2009
|
+
"gate_root",
|
|
1616
2010
|
"guide",
|
|
1617
2011
|
"guide_topics",
|
|
1618
2012
|
"guide_choices",
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
"""The adoptable-capability registry behind ``terp inspect capabilities``.
|
|
2
|
+
|
|
3
|
+
The gate tells an author what they may **not** do; nothing told them what the platform
|
|
4
|
+
already **offers**. An app that needs run progress hand-rolls polling, an app that needs
|
|
5
|
+
durable delivery fires a post-commit callback and hopes — not because the author was
|
|
6
|
+
careless, but because ``terp-cap-realtime`` and ``terp-cap-outbox`` were invisible from
|
|
7
|
+
inside the project. Discoverability is a platform responsibility, so it ships as a
|
|
8
|
+
command: one screen listing every capability, whether this app has it, the exact
|
|
9
|
+
``uv add`` line, and the composition-root shape it expects.
|
|
10
|
+
|
|
11
|
+
The registry is static (it must describe capabilities that are *not* installed), so it
|
|
12
|
+
is pinned against the real packages by ``tests/architecture/test_cli_capabilities.py``:
|
|
13
|
+
every capability directory appears here exactly once, and ``kind`` agrees with whether
|
|
14
|
+
the package declares a ``terp.capabilities`` router entry point.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import json
|
|
20
|
+
from dataclasses import dataclass
|
|
21
|
+
from importlib import metadata
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True)
|
|
25
|
+
class Capability:
|
|
26
|
+
"""One adoptable ``terp-cap-*`` package, as an author needs to see it."""
|
|
27
|
+
|
|
28
|
+
name: str
|
|
29
|
+
"""Package suffix, e.g. ``realtime`` (dist ``terp-cap-realtime``)."""
|
|
30
|
+
|
|
31
|
+
summary: str
|
|
32
|
+
"""One line: what it gives you, in problem terms."""
|
|
33
|
+
|
|
34
|
+
kind: str
|
|
35
|
+
"""``routed`` (discovery mounts a router) or ``library`` (you wire it yourself)."""
|
|
36
|
+
|
|
37
|
+
wiring: str
|
|
38
|
+
"""The composition-root shape, copy-pasteable."""
|
|
39
|
+
|
|
40
|
+
guide: str | None = None
|
|
41
|
+
"""``terp guide`` topic covering it, when one exists."""
|
|
42
|
+
|
|
43
|
+
@property
|
|
44
|
+
def distribution(self) -> str:
|
|
45
|
+
"""The PyPI distribution name."""
|
|
46
|
+
return "terp-cap-" + self.name.replace("_", "-")
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
def module(self) -> str:
|
|
50
|
+
"""The import path."""
|
|
51
|
+
return "terp.capabilities." + self.name
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
CAPABILITIES: tuple[Capability, ...] = (
|
|
55
|
+
Capability(
|
|
56
|
+
name="access",
|
|
57
|
+
summary="RBAC permission grants + a fail-closed require_permission dependency.",
|
|
58
|
+
kind="routed",
|
|
59
|
+
wiring="create_app(..., discover_capabilities=True)",
|
|
60
|
+
guide="access",
|
|
61
|
+
),
|
|
62
|
+
Capability(
|
|
63
|
+
name="audit",
|
|
64
|
+
summary="Durable append-only sink for the core audit seam (who changed what).",
|
|
65
|
+
kind="routed",
|
|
66
|
+
wiring="create_app(..., audit_sink=persist_audit)",
|
|
67
|
+
),
|
|
68
|
+
Capability(
|
|
69
|
+
name="auth",
|
|
70
|
+
summary="Argon2 password hashing, JWT access tokens, the get_principal seam.",
|
|
71
|
+
kind="library",
|
|
72
|
+
wiring="mount the login router in app/auth.py",
|
|
73
|
+
guide="passwords",
|
|
74
|
+
),
|
|
75
|
+
Capability(
|
|
76
|
+
name="eventbus",
|
|
77
|
+
summary="In-process dispatcher for the core event seam — react without coupling.",
|
|
78
|
+
kind="library",
|
|
79
|
+
wiring="create_app(..., event_dispatcher=dispatch_in_process)",
|
|
80
|
+
guide="events",
|
|
81
|
+
),
|
|
82
|
+
Capability(
|
|
83
|
+
name="files",
|
|
84
|
+
summary="Owner-scoped file objects: metadata in the DB, bytes behind a storage port.",
|
|
85
|
+
kind="routed",
|
|
86
|
+
wiring="create_app(..., discover_capabilities=True)",
|
|
87
|
+
guide="files",
|
|
88
|
+
),
|
|
89
|
+
Capability(
|
|
90
|
+
name="groups",
|
|
91
|
+
summary="Admin-managed user groups that bundle access-grant permissions.",
|
|
92
|
+
kind="routed",
|
|
93
|
+
wiring="create_app(..., discover_capabilities=True)",
|
|
94
|
+
guide="access",
|
|
95
|
+
),
|
|
96
|
+
Capability(
|
|
97
|
+
name="identity",
|
|
98
|
+
summary="Persisted user store backing authentication.",
|
|
99
|
+
kind="library",
|
|
100
|
+
wiring="create_app(..., discover_capabilities=True)",
|
|
101
|
+
),
|
|
102
|
+
Capability(
|
|
103
|
+
name="jobs_celery",
|
|
104
|
+
summary="Run catalog jobs on a Celery broker, with zero domain change.",
|
|
105
|
+
kind="library",
|
|
106
|
+
wiring="create_app(..., job_queue=CeleryJobQueue(...))",
|
|
107
|
+
guide="jobs",
|
|
108
|
+
),
|
|
109
|
+
Capability(
|
|
110
|
+
name="oidc",
|
|
111
|
+
summary="Single sign-on via the OpenID Connect code flow with PKCE.",
|
|
112
|
+
kind="library",
|
|
113
|
+
wiring="build_oidc_module(...) in app/auth.py, mounted when OIDC_* is set",
|
|
114
|
+
),
|
|
115
|
+
Capability(
|
|
116
|
+
name="outbox",
|
|
117
|
+
summary="Transactional, leased, retrying post-commit delivery for jobs and events.",
|
|
118
|
+
kind="library",
|
|
119
|
+
wiring=(
|
|
120
|
+
"create_app(..., job_queue=OutboxJobQueue(), "
|
|
121
|
+
"event_dispatcher=outbox_event_dispatcher)"
|
|
122
|
+
),
|
|
123
|
+
guide="outbox",
|
|
124
|
+
),
|
|
125
|
+
Capability(
|
|
126
|
+
name="realtime",
|
|
127
|
+
summary="Typed, policy-gated SSE/WebSocket channels with one-use connection tickets.",
|
|
128
|
+
kind="routed",
|
|
129
|
+
wiring="create_app(..., discover_capabilities=True) + configure_realtime(...)",
|
|
130
|
+
guide="realtime",
|
|
131
|
+
),
|
|
132
|
+
Capability(
|
|
133
|
+
name="redis",
|
|
134
|
+
summary="Shared idempotency / throttling / cache state for multi-replica deploys.",
|
|
135
|
+
kind="library",
|
|
136
|
+
wiring="create_app(..., idempotency_store=RedisIdempotencyStore(...))",
|
|
137
|
+
guide="idempotency",
|
|
138
|
+
),
|
|
139
|
+
Capability(
|
|
140
|
+
name="scheduler_apscheduler",
|
|
141
|
+
summary="Fire catalog schedules on their cron in-process, through the jobs seam.",
|
|
142
|
+
kind="library",
|
|
143
|
+
wiring="terp jobs scheduler",
|
|
144
|
+
guide="jobs",
|
|
145
|
+
),
|
|
146
|
+
Capability(
|
|
147
|
+
name="scheduler_celery_beat",
|
|
148
|
+
summary="Drive catalog schedules from Celery beat, through the jobs seam.",
|
|
149
|
+
kind="library",
|
|
150
|
+
wiring="celery beat against the generated schedule",
|
|
151
|
+
guide="jobs",
|
|
152
|
+
),
|
|
153
|
+
Capability(
|
|
154
|
+
name="sync",
|
|
155
|
+
summary="Reconcile a local entity against an external system on the jobs seam.",
|
|
156
|
+
kind="library",
|
|
157
|
+
wiring="declare a SyncSpec; run it through the jobs/scheduler seam",
|
|
158
|
+
guide="jobs",
|
|
159
|
+
),
|
|
160
|
+
Capability(
|
|
161
|
+
name="tenancy",
|
|
162
|
+
summary="Tenant isolation by construction (session-level filter + insert stamp).",
|
|
163
|
+
kind="library",
|
|
164
|
+
wiring="install_tenancy(...) at the composition root",
|
|
165
|
+
guide="tenancy",
|
|
166
|
+
),
|
|
167
|
+
Capability(
|
|
168
|
+
name="users",
|
|
169
|
+
summary="Admin user management over the identity store.",
|
|
170
|
+
kind="routed",
|
|
171
|
+
wiring="create_app(..., discover_capabilities=True)",
|
|
172
|
+
),
|
|
173
|
+
Capability(
|
|
174
|
+
name="webhooks",
|
|
175
|
+
summary="Reliable, signed, SSRF-guarded outbound webhooks on the jobs/outbox seam.",
|
|
176
|
+
kind="routed",
|
|
177
|
+
wiring="create_app(..., discover_capabilities=True)",
|
|
178
|
+
),
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _is_installed(capability: Capability) -> bool:
|
|
183
|
+
"""Whether *capability*'s distribution is resolvable in this environment."""
|
|
184
|
+
try:
|
|
185
|
+
metadata.distribution(capability.distribution)
|
|
186
|
+
except metadata.PackageNotFoundError:
|
|
187
|
+
return False
|
|
188
|
+
return True
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def render_capabilities(*, fmt: str = "text") -> str:
|
|
192
|
+
"""Render every adoptable capability, marking the ones this app already has.
|
|
193
|
+
|
|
194
|
+
Answers the question the gate never could: *what else is on the shelf?* Installed
|
|
195
|
+
capabilities are listed first so the reader sees the current profile, then the
|
|
196
|
+
adoptable ones with the exact ``uv add`` line and wiring shape — no package index
|
|
197
|
+
search, no guessing at a name.
|
|
198
|
+
"""
|
|
199
|
+
rows = [(cap, _is_installed(cap)) for cap in CAPABILITIES]
|
|
200
|
+
if fmt == "json":
|
|
201
|
+
return json.dumps(
|
|
202
|
+
{
|
|
203
|
+
"capabilities": [
|
|
204
|
+
{
|
|
205
|
+
"name": cap.name,
|
|
206
|
+
"distribution": cap.distribution,
|
|
207
|
+
"module": cap.module,
|
|
208
|
+
"summary": cap.summary,
|
|
209
|
+
"kind": cap.kind,
|
|
210
|
+
"wiring": cap.wiring,
|
|
211
|
+
"guide": cap.guide,
|
|
212
|
+
"installed": installed,
|
|
213
|
+
}
|
|
214
|
+
for cap, installed in rows
|
|
215
|
+
]
|
|
216
|
+
},
|
|
217
|
+
indent=2,
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
installed = [cap for cap, present in rows if present]
|
|
221
|
+
available = [cap for cap, present in rows if not present]
|
|
222
|
+
lines = [
|
|
223
|
+
"Capabilities",
|
|
224
|
+
"",
|
|
225
|
+
"Opt-in packages the platform maintains. A `routed` capability mounts its own",
|
|
226
|
+
"router when discovery is on; a `library` capability is imported and wired at",
|
|
227
|
+
"the composition root. Adopting one is always: add the dependency, wire it, run",
|
|
228
|
+
"`uv run terp migrate` if it ships tables.",
|
|
229
|
+
"",
|
|
230
|
+
f"Installed in this app ({len(installed)})",
|
|
231
|
+
"",
|
|
232
|
+
]
|
|
233
|
+
for cap in installed:
|
|
234
|
+
lines.append(f" {cap.distribution:<32} {cap.kind}")
|
|
235
|
+
lines.append(f" {cap.summary}")
|
|
236
|
+
if not installed:
|
|
237
|
+
lines.append(" (none)")
|
|
238
|
+
lines += ["", f"Available to adopt ({len(available)})", ""]
|
|
239
|
+
for cap in available:
|
|
240
|
+
lines.append(f" {cap.distribution:<32} {cap.kind}")
|
|
241
|
+
lines.append(f" {cap.summary}")
|
|
242
|
+
lines.append(f" uv add {cap.distribution}")
|
|
243
|
+
lines.append(f" {cap.wiring}")
|
|
244
|
+
if cap.guide is not None:
|
|
245
|
+
lines.append(f" uv run terp guide {cap.guide}")
|
|
246
|
+
if not available:
|
|
247
|
+
lines.append(" (none — every maintained capability is installed)")
|
|
248
|
+
lines += [
|
|
249
|
+
"",
|
|
250
|
+
"Do not hand-roll what a capability owns. If you need something none of these",
|
|
251
|
+
"covers, stop and report the missing capability rather than building a local",
|
|
252
|
+
"substitute (`uv run terp guide capability`).",
|
|
253
|
+
]
|
|
254
|
+
return "\n".join(lines)
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
"""``terp grant`` — assign, list and revoke a subject's permission grants.
|
|
2
|
+
|
|
3
|
+
Terp has had per-subject permission grants since ADR 0013, and almost nobody uses
|
|
4
|
+
them. The reason is not that operators prefer broad roles; it is that granting was
|
|
5
|
+
the expensive option:
|
|
6
|
+
|
|
7
|
+
- it needed an **admin token**, so you had to already be the thing you were trying
|
|
8
|
+
to avoid needing;
|
|
9
|
+
- it needed the subject's **UUID**, which nobody has — people have an email address
|
|
10
|
+
or the name of an integration;
|
|
11
|
+
- it needed the **exact permission string**, and nothing anywhere would tell you
|
|
12
|
+
which strings the app actually checks.
|
|
13
|
+
|
|
14
|
+
Faced with that, the rational move is to bump the account to a role that already
|
|
15
|
+
clears the check and move on. Least privilege loses to a ten-second workaround every
|
|
16
|
+
time. So this command removes all three costs: it resolves a subject from an email or
|
|
17
|
+
a service-account name, it validates the permission against the app's own catalog and
|
|
18
|
+
*prints the catalog* when you get it wrong, and it writes through the audited
|
|
19
|
+
``AccessService`` chokepoint without an HTTP round trip.
|
|
20
|
+
|
|
21
|
+
It deliberately does **not** grow a ``--force`` for an unknown permission. A grant of
|
|
22
|
+
a string the app never checks is not a lenient grant, it is a silent no-op — and the
|
|
23
|
+
person who wrote it will believe the integration is authorized until it is not.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import contextlib
|
|
29
|
+
import pathlib
|
|
30
|
+
import uuid
|
|
31
|
+
|
|
32
|
+
from fastapi import FastAPI
|
|
33
|
+
from sqlmodel import Session
|
|
34
|
+
|
|
35
|
+
from terp.cli._appref import load_app, push_app_root
|
|
36
|
+
from terp.core.db import get_session
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _catalog(app: FastAPI) -> dict[str, str]:
|
|
40
|
+
"""The app's declared permissions, name -> minimum role name.
|
|
41
|
+
|
|
42
|
+
Read off the composed app's control plane rather than a static list, so the
|
|
43
|
+
command can only ever offer permissions this app really enforces.
|
|
44
|
+
"""
|
|
45
|
+
plane = getattr(getattr(app, "state", None), "terp_control_plane", None)
|
|
46
|
+
if plane is None:
|
|
47
|
+
raise SystemExit(
|
|
48
|
+
"the app exposes no control plane (create_app records it on app.state) — "
|
|
49
|
+
"pass a terp app factory, e.g. --app app.main:build"
|
|
50
|
+
)
|
|
51
|
+
return {
|
|
52
|
+
permission.name: permission.min_role.name
|
|
53
|
+
for permission in plane.permissions.permissions
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _resolve_permission(app: FastAPI, permission: str) -> str:
|
|
58
|
+
"""Check *permission* against the app's catalog, or fail listing the choices.
|
|
59
|
+
|
|
60
|
+
The error is the feature: "unknown permission" alone sends the reader back to
|
|
61
|
+
grepping the source, which is the moment they give up and widen the role instead.
|
|
62
|
+
"""
|
|
63
|
+
catalog = _catalog(app)
|
|
64
|
+
if permission in catalog:
|
|
65
|
+
return permission
|
|
66
|
+
known = "\n".join(
|
|
67
|
+
f" {name} (needs role {min_role} or higher)"
|
|
68
|
+
for name, min_role in sorted(catalog.items())
|
|
69
|
+
)
|
|
70
|
+
raise SystemExit(
|
|
71
|
+
f"unknown permission {permission!r}. This app declares:\n{known}\n\n"
|
|
72
|
+
"A grant of a permission the app never checks is a silent no-op, so it is "
|
|
73
|
+
"refused rather than stored."
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _resolve_subject(session: Session, subject: str) -> tuple[uuid.UUID, str]:
|
|
78
|
+
"""Resolve *subject* — a UUID, a user email, or a service-account name.
|
|
79
|
+
|
|
80
|
+
Grants are keyed by a bare subject id with no foreign key (ADR 0013), which is
|
|
81
|
+
what lets a user, a service account and a group all be granted the same way. The
|
|
82
|
+
cost is that the id means nothing to a human, so this command accepts what people
|
|
83
|
+
actually have and does the lookup itself.
|
|
84
|
+
"""
|
|
85
|
+
with contextlib.suppress(ValueError):
|
|
86
|
+
return uuid.UUID(subject), f"subject {subject}"
|
|
87
|
+
|
|
88
|
+
if "@" in subject:
|
|
89
|
+
from terp.capabilities.users import UsersService
|
|
90
|
+
|
|
91
|
+
user = UsersService().get_by_email(session, subject)
|
|
92
|
+
if user is None:
|
|
93
|
+
raise SystemExit(f"no user with email {subject!r}")
|
|
94
|
+
return user.id, f"user {subject!r}"
|
|
95
|
+
|
|
96
|
+
from terp.capabilities.identity import ServiceAccountService
|
|
97
|
+
|
|
98
|
+
account = ServiceAccountService().get_by_name(session, subject)
|
|
99
|
+
if account is None:
|
|
100
|
+
raise SystemExit(
|
|
101
|
+
f"no service account named {subject!r} (pass an email for a user, a "
|
|
102
|
+
"service-account name for a machine, or a subject UUID for anything else)"
|
|
103
|
+
)
|
|
104
|
+
return account.id, f"service account {subject!r}"
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _load(app_ref: str, app_root: str | pathlib.Path) -> FastAPI:
|
|
108
|
+
push_app_root(app_root)
|
|
109
|
+
return load_app(app_ref)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _rank_shortfall(
|
|
113
|
+
app: FastAPI, session: Session, subject: str, permission: str
|
|
114
|
+
) -> str | None:
|
|
115
|
+
"""Describe the subject's role if it sits below *permission*'s minimum, else None.
|
|
116
|
+
|
|
117
|
+
Only answerable for subjects that carry a rank of their own — a bare UUID or a
|
|
118
|
+
group has none, and for those the question belongs to whatever the id resolves to.
|
|
119
|
+
"""
|
|
120
|
+
plane = app.state.terp_control_plane
|
|
121
|
+
# The caller resolved *permission* against this same catalog, so the lookup cannot
|
|
122
|
+
# miss — an undeclared permission never reaches this point.
|
|
123
|
+
declared = next(p for p in plane.permissions.permissions if p.name == permission)
|
|
124
|
+
|
|
125
|
+
rank: int | None = None
|
|
126
|
+
if "@" in subject:
|
|
127
|
+
from terp.capabilities.users import UsersService
|
|
128
|
+
|
|
129
|
+
user = UsersService().get_by_email(session, subject)
|
|
130
|
+
rank = None if user is None else user.role
|
|
131
|
+
else:
|
|
132
|
+
from terp.capabilities.identity import ServiceAccountService
|
|
133
|
+
|
|
134
|
+
account = ServiceAccountService().get_by_name(session, subject)
|
|
135
|
+
rank = None if account is None else account.role
|
|
136
|
+
|
|
137
|
+
if rank is None or rank >= declared.min_role.rank:
|
|
138
|
+
return None
|
|
139
|
+
return f"{plane.permissions.role_for_rank(rank).name} (rank {rank})"
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def grant_add_command(
|
|
143
|
+
subject: str,
|
|
144
|
+
permission: str,
|
|
145
|
+
*,
|
|
146
|
+
app_ref: str = "app.main:app",
|
|
147
|
+
app_root: str | pathlib.Path = ".",
|
|
148
|
+
) -> str:
|
|
149
|
+
"""Grant *permission* to *subject*, audited and idempotent."""
|
|
150
|
+
app = _load(app_ref, app_root)
|
|
151
|
+
name = _resolve_permission(app, permission)
|
|
152
|
+
min_role = _catalog(app)[name]
|
|
153
|
+
|
|
154
|
+
from terp.capabilities.access import AccessService
|
|
155
|
+
from terp.core import AppError
|
|
156
|
+
|
|
157
|
+
with contextlib.closing(get_session()) as gen:
|
|
158
|
+
session = next(gen)
|
|
159
|
+
subject_id, label = _resolve_subject(session, subject)
|
|
160
|
+
try:
|
|
161
|
+
AccessService().grant(session, subject_id, name)
|
|
162
|
+
except AppError as exc:
|
|
163
|
+
raise SystemExit(f"could not grant {name!r} to {label}: {exc}") from exc
|
|
164
|
+
shortfall = _rank_shortfall(app, session, subject, name)
|
|
165
|
+
granted = f"granted {name!r} to {label} ({subject_id})"
|
|
166
|
+
if shortfall is None:
|
|
167
|
+
return granted
|
|
168
|
+
# The guard checks rank before it checks grants, so a grant to a subject below the
|
|
169
|
+
# permission's minimum role is stored but can never fire. Saying so here is the
|
|
170
|
+
# whole point of the command: a grant that silently does nothing is worse than no
|
|
171
|
+
# grant, because the operator walks away believing the integration is authorized.
|
|
172
|
+
return (
|
|
173
|
+
f"{granted}\n"
|
|
174
|
+
f" WARNING: {name!r} requires role {min_role} or higher and this subject is "
|
|
175
|
+
f"{shortfall}. The grant is stored but will not take effect until the "
|
|
176
|
+
"subject's role rank reaches that minimum."
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def grant_revoke_command(
|
|
181
|
+
subject: str,
|
|
182
|
+
permission: str,
|
|
183
|
+
*,
|
|
184
|
+
app_ref: str = "app.main:app",
|
|
185
|
+
app_root: str | pathlib.Path = ".",
|
|
186
|
+
) -> str:
|
|
187
|
+
"""Revoke *permission* from *subject*.
|
|
188
|
+
|
|
189
|
+
The permission is **not** validated against the catalog here: a permission the app
|
|
190
|
+
has since stopped declaring is exactly the stale grant you most want to be able to
|
|
191
|
+
clean up.
|
|
192
|
+
"""
|
|
193
|
+
_load(app_ref, app_root)
|
|
194
|
+
|
|
195
|
+
from terp.capabilities.access import AccessService
|
|
196
|
+
|
|
197
|
+
with contextlib.closing(get_session()) as gen:
|
|
198
|
+
session = next(gen)
|
|
199
|
+
subject_id, label = _resolve_subject(session, subject)
|
|
200
|
+
removed = AccessService().revoke(session, subject_id, permission)
|
|
201
|
+
if not removed:
|
|
202
|
+
return f"{label} did not hold {permission!r}; nothing to revoke"
|
|
203
|
+
return f"revoked {permission!r} from {label} ({subject_id})"
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def grant_list_command(
|
|
207
|
+
subject: str,
|
|
208
|
+
*,
|
|
209
|
+
app_ref: str = "app.main:app",
|
|
210
|
+
app_root: str | pathlib.Path = ".",
|
|
211
|
+
) -> str:
|
|
212
|
+
"""List everything *subject* holds — the answer to "why can it do that?".
|
|
213
|
+
|
|
214
|
+
Grants inherited through group membership are included, because the question being
|
|
215
|
+
asked is what this subject can do, not which rows happen to name it.
|
|
216
|
+
"""
|
|
217
|
+
app = _load(app_ref, app_root)
|
|
218
|
+
catalog = _catalog(app)
|
|
219
|
+
|
|
220
|
+
from terp.capabilities.access import AccessService
|
|
221
|
+
|
|
222
|
+
with contextlib.closing(get_session()) as gen:
|
|
223
|
+
session = next(gen)
|
|
224
|
+
subject_id, label = _resolve_subject(session, subject)
|
|
225
|
+
held = sorted(AccessService().permissions_for(session, subject_id))
|
|
226
|
+
if not held:
|
|
227
|
+
return f"{label} ({subject_id}) holds no permission grants"
|
|
228
|
+
lines = "\n".join(
|
|
229
|
+
f" {name}"
|
|
230
|
+
+ ("" if name in catalog else " [stale: this app no longer declares it]")
|
|
231
|
+
for name in held
|
|
232
|
+
)
|
|
233
|
+
return f"{label} ({subject_id}) holds:\n{lines}"
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""``terp service-account create`` — provision a machine credential against the store.
|
|
2
|
+
|
|
3
|
+
The bootstrap problem is the same one ``terp user create`` solves, one step further
|
|
4
|
+
along: an integration needs a credential before it can call anything, and the only
|
|
5
|
+
alternative to a command like this is for somebody to hand it a person's password.
|
|
6
|
+
Given how that ends — a shared admin login nobody can revoke without breaking a
|
|
7
|
+
production job — the machine path has to be at least as easy as the wrong one.
|
|
8
|
+
|
|
9
|
+
The generated secret is printed **once**, to stdout, and never stored in the clear.
|
|
10
|
+
There is no command to read it back; a lost secret is re-provisioned. The account's
|
|
11
|
+
role rank is required, not defaulted, because the whole point is that an integration's
|
|
12
|
+
authority is chosen rather than inherited.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import contextlib
|
|
18
|
+
import datetime
|
|
19
|
+
import pathlib
|
|
20
|
+
|
|
21
|
+
from terp.cli._appref import load_app, push_app_root
|
|
22
|
+
from terp.cli.users import resolve_role
|
|
23
|
+
from terp.core.db import get_session
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def create_service_account_command(
|
|
27
|
+
name: str,
|
|
28
|
+
*,
|
|
29
|
+
role: str,
|
|
30
|
+
app_ref: str = "app.main:app",
|
|
31
|
+
app_root: str | pathlib.Path = ".",
|
|
32
|
+
description: str | None = None,
|
|
33
|
+
expires_in_days: int | None = 365,
|
|
34
|
+
) -> str:
|
|
35
|
+
"""Build *app_ref*, then provision service account *name* at *role*, audited.
|
|
36
|
+
|
|
37
|
+
*expires_in_days* defaults to a year rather than to "never": a machine credential
|
|
38
|
+
outlives the ticket that justified it and the person who created it, so an end date
|
|
39
|
+
it has to be renewed past is the only thing that reliably forces a second look.
|
|
40
|
+
Pass ``None`` for a non-expiring credential — deliberately, and in writing.
|
|
41
|
+
"""
|
|
42
|
+
push_app_root(app_root)
|
|
43
|
+
load_app(app_ref)
|
|
44
|
+
|
|
45
|
+
from terp.capabilities.identity import ServiceAccountService
|
|
46
|
+
from terp.capabilities.identity.schemas import ServiceAccountCreate
|
|
47
|
+
from terp.core import AppError
|
|
48
|
+
|
|
49
|
+
rank = resolve_role(role)
|
|
50
|
+
expires_at = (
|
|
51
|
+
datetime.datetime.now(datetime.UTC) + datetime.timedelta(days=expires_in_days)
|
|
52
|
+
if expires_in_days is not None
|
|
53
|
+
else None
|
|
54
|
+
)
|
|
55
|
+
service = ServiceAccountService()
|
|
56
|
+
with contextlib.closing(get_session()) as gen:
|
|
57
|
+
session = next(gen)
|
|
58
|
+
try:
|
|
59
|
+
account, secret = service.provision(
|
|
60
|
+
session,
|
|
61
|
+
ServiceAccountCreate(
|
|
62
|
+
name=name,
|
|
63
|
+
role_rank=rank,
|
|
64
|
+
description=description,
|
|
65
|
+
expires_at=expires_at,
|
|
66
|
+
),
|
|
67
|
+
)
|
|
68
|
+
except AppError as exc:
|
|
69
|
+
raise SystemExit(f"could not create service account {name!r}: {exc}") from exc
|
|
70
|
+
|
|
71
|
+
expiry = account.expires_at.date().isoformat() if account.expires_at else "never"
|
|
72
|
+
return (
|
|
73
|
+
f"created service account {name!r} (id {account.id}, role rank {rank}, "
|
|
74
|
+
f"expires {expiry})\n"
|
|
75
|
+
f" client_id: {account.client_id}\n"
|
|
76
|
+
f" client_secret: {secret}\n"
|
|
77
|
+
"\nThe secret is shown once and is not recoverable. Store it in the "
|
|
78
|
+
"integration's secret store now; if it is lost, provision a new account."
|
|
79
|
+
)
|
|
@@ -255,9 +255,86 @@ def _reports_in(stdout: str) -> list[dict]:
|
|
|
255
255
|
]
|
|
256
256
|
|
|
257
257
|
|
|
258
|
+
def _node_platform() -> tuple[str, str]:
|
|
259
|
+
"""This machine as npm names it — ``process.platform`` / ``process.arch``."""
|
|
260
|
+
import platform as _platform
|
|
261
|
+
|
|
262
|
+
system = {"win32": "win32", "darwin": "darwin"}.get(sys.platform, "linux")
|
|
263
|
+
machine = _platform.machine().lower()
|
|
264
|
+
arch = {
|
|
265
|
+
"amd64": "x64",
|
|
266
|
+
"x86_64": "x64",
|
|
267
|
+
"aarch64": "arm64",
|
|
268
|
+
"arm64": "arm64",
|
|
269
|
+
}.get(machine, machine)
|
|
270
|
+
return system, arch
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _node_modules_problem(root: pathlib.Path) -> str | None:
|
|
274
|
+
"""Explain an unusable ``frontend/node_modules``, or None if it looks fine.
|
|
275
|
+
|
|
276
|
+
An npm install is platform-specific: the native binaries a bundler needs are
|
|
277
|
+
optional dependencies gated on ``os``/``cpu``, so a tree installed on the
|
|
278
|
+
Windows host has no Linux binary, and running the gate in the container dies
|
|
279
|
+
with a raw Node stack (``Cannot find module '@rolldown/binding-linux-x64-gnu'``)
|
|
280
|
+
that names neither the cause nor the fix. Every Terp failure states the fix, so
|
|
281
|
+
this is detected up front rather than left to the reader to decode.
|
|
282
|
+
|
|
283
|
+
The lockfile already records which optional packages belong on which platform,
|
|
284
|
+
so the check is exact and needs no list of native package names to maintain.
|
|
285
|
+
"""
|
|
286
|
+
frontend = root / "frontend"
|
|
287
|
+
if not (frontend / "package.json").is_file():
|
|
288
|
+
return None
|
|
289
|
+
modules = frontend / "node_modules"
|
|
290
|
+
if not modules.is_dir():
|
|
291
|
+
return (
|
|
292
|
+
"frontend/node_modules is missing — the frontend checks cannot run.\n"
|
|
293
|
+
" Fix: npm --prefix frontend ci"
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
lockfile = frontend / "package-lock.json"
|
|
297
|
+
if not lockfile.is_file():
|
|
298
|
+
return None
|
|
299
|
+
try:
|
|
300
|
+
packages = json.loads(lockfile.read_text(encoding="utf-8")).get("packages", {})
|
|
301
|
+
except (OSError, ValueError):
|
|
302
|
+
return None
|
|
303
|
+
|
|
304
|
+
system, arch = _node_platform()
|
|
305
|
+
missing = [
|
|
306
|
+
name
|
|
307
|
+
for name, entry in packages.items()
|
|
308
|
+
if name.startswith("node_modules/")
|
|
309
|
+
and isinstance(entry, dict)
|
|
310
|
+
# Only packages this platform is *supposed* to have: an entry with no os/cpu
|
|
311
|
+
# constraint is platform-neutral, and one constrained elsewhere is absent by
|
|
312
|
+
# design rather than by a bad install.
|
|
313
|
+
and system in (entry.get("os") or [system])
|
|
314
|
+
and arch in (entry.get("cpu") or [arch])
|
|
315
|
+
and (entry.get("os") or entry.get("cpu"))
|
|
316
|
+
and not (frontend / name).exists()
|
|
317
|
+
]
|
|
318
|
+
if not missing:
|
|
319
|
+
return None
|
|
320
|
+
return (
|
|
321
|
+
f"frontend/node_modules was installed for a different platform: "
|
|
322
|
+
f"{len(missing)} package(s) this machine ({system}/{arch}) needs are absent, "
|
|
323
|
+
f"e.g. {missing[0].removeprefix('node_modules/')}.\n"
|
|
324
|
+
" This happens when the tree is installed on the host and the gate runs in "
|
|
325
|
+
"a container (or vice versa); native binaries are per-platform optional "
|
|
326
|
+
"dependencies and do not travel.\n"
|
|
327
|
+
" Fix: npm --prefix frontend ci (run it where the gate runs)"
|
|
328
|
+
)
|
|
329
|
+
|
|
330
|
+
|
|
258
331
|
def _run_subprocess(check: VerifyCheck, root: pathlib.Path) -> tuple[int, str]:
|
|
259
332
|
"""Run one manifest command (shell-less; ``&&`` composites never land here)."""
|
|
260
333
|
argv = shlex.split(check.command)
|
|
334
|
+
if argv and argv[0] == "npm":
|
|
335
|
+
problem = _node_modules_problem(root)
|
|
336
|
+
if problem is not None:
|
|
337
|
+
return 1, problem
|
|
261
338
|
executable = shutil.which(argv[0]) or argv[0]
|
|
262
339
|
try:
|
|
263
340
|
completed = subprocess.run( # noqa: S603 - fixed manifest argv, shell=False
|
terp_cli-0.3.0/PKG-INFO
DELETED
|
@@ -1,16 +0,0 @@
|
|
|
1
|
-
Metadata-Version: 2.4
|
|
2
|
-
Name: terp-cli
|
|
3
|
-
Version: 0.3.0
|
|
4
|
-
Summary: Terp command-line tool — inspect, scaffolding, migrations, checks, api-docs.
|
|
5
|
-
License-Expression: Apache-2.0
|
|
6
|
-
Requires-Python: >=3.13
|
|
7
|
-
Requires-Dist: terp-arch==0.3.0
|
|
8
|
-
Requires-Dist: terp-core==0.3.0
|
|
9
|
-
Requires-Dist: terp-migrations==0.3.0
|
|
10
|
-
Provides-Extra: jobs
|
|
11
|
-
Requires-Dist: terp-cap-outbox==0.3.0; extra == 'jobs'
|
|
12
|
-
Requires-Dist: terp-cap-scheduler-apscheduler==0.3.0; extra == 'jobs'
|
|
13
|
-
Provides-Extra: scheduler
|
|
14
|
-
Requires-Dist: terp-cap-scheduler-apscheduler==0.3.0; extra == 'scheduler'
|
|
15
|
-
Provides-Extra: worker
|
|
16
|
-
Requires-Dist: terp-cap-outbox==0.3.0; extra == 'worker'
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|