Operations CLI
Admin work tends to happen in one-off scripts: requeue the failed runs, fix a tenant, backfill
a column. Each script builds a runtime and calls a handler, or goes straight to the database,
outside authz, tenancy, read-only enforcement, idempotency and audit. forze ops is the front
door those scripts lack. It lists, describes and calls the operations your registry already
holds. Every call validates its input, verifies who is calling, resolves the tenant and runs
through the same pipeline an HTTP or MCP caller goes through.
Declare what the CLI exposes¶
An OpsApp names the registry, the runtime a call runs on, and who calls run as. Exposure
follows MCP: queries only by default, commands with include_writes=True, and an optional
allowlist that is checked like a grant. Sensitive read models are refused. Operations whose
result is a byte stream (streams=True on the descriptor) are left out, since a call prints
JSON.
from forze.application.contracts.authn import AuthnSpec
from forze.application.contracts.document import DocumentSpec, DocumentWriteTypes
from forze.application.execution import DeploymentProfile, ExecutionRuntime, build_runtime
from forze.domain.models import BaseDTO, Document, ReadDocument
from forze_cli.ops import ApiKeyIdentity, OpsApp
from forze_kits.aggregates.document.factories import build_document_registry
from forze_mock import MockDepsModule, MockState
ops = OpsApp(
registry=build_document_registry(SPEC).freeze(),
runtime=build_ops_runtime,
# Who each call runs as: the principal a key from $FORZE_API_KEY authenticates.
identity=ApiKeyIdentity(AuthnSpec(name="main", enabled_methods=frozenset({"api_key"}))),
include_writes=True,
# With writes on, name what is exposed: an allowlist is reviewed like a grant.
operations=["notes.create", "notes.get", "notes.list"],
)
The declaration is checked when it is built. An unknown allowlisted key, a command without
include_writes, an allowlisted streaming operation, or a sensitive operation fails while
your module imports. forze_cli.ops
imports neither Typer nor anything else from the cli extra, so the web process can import the
same module.
Unguarded operations become reachable
An operation that relies on its route for protection has none here. The tenancy admin
kit, for one, ships unguarded by design. When include_writes is on, keep an allowlist.
A sensitive operation fails the declaration even when you didn't name it, because the default
takes every query. The storage kit's presign_download is one, since its result is a bearer
credential. An OpsApp over a registry that holds the storage kit needs an operations=
allowlist that leaves it out.
Build a one-shot runtime¶
runtime is a factory. list and describe never call it, so they need no credentials and
open no pool. call builds the runtime once, enters its scope, runs the operation and shuts
the runtime down.
def build_ops_runtime() -> ExecutionRuntime:
"""The runtime one ``forze ops call`` runs on, built fresh per call.
``SERVERLESS``: assembly refuses a lifecycle step declared ``requires_long_running``,
and the drain window is zero, so a one-shot process never starts a poller it would
abandon. Wire only the deps the exposed operations need.
"""
return build_runtime(MockDepsModule(state=STATE), deployment=DeploymentProfile.SERVERLESS)
The runtime must be DeploymentProfile.SERVERLESS, or call exits 78. That profile refuses
lifecycle steps declared requires_long_running and drops the drain window to zero, which is
what a process that exits after one call needs. The check trusts the declaration, though. A
poller whose author never declared it still starts, so build this runtime from only the deps
and steps the exposed operations use.
Choose who a call runs as¶
The declaration must name an identity. There is no default, and no option picks a principal at run time.
| Identity | Binds | Use it for |
|---|---|---|
ApiKeyIdentity(authn_spec, env="FORZE_API_KEY", tenancy_route=None) |
the principal a verified key authenticates, and its tenant | per-operator or per-job keys: real attribution, revocable |
AnonymousIdentity() |
nothing | operations with no authn requirement |
StaticIdentity(authn=…, tenant=…, acknowledge_unverified_identity=True) |
a fixed identity nothing verifies | single-tenant tools, break-glass inside the cluster |
API key. The key is read from the environment variable env, or from
--api-key-file PATH. It is never read from argv, which ps and shell history show. It is
verified through the same authn port HTTP and MCP use, so expiry, revocation, eligibility and
delegation keys behave the same on every surface. The tenant resolves from the principal on
tenancy_route, which defaults to the authn spec's name. --tenant UUID requests a tenant;
the resolver validates it against the principal's membership.
Static identity. It costs three things, so it is refused unless you acknowledge it:
- there is no expiry, revocation or eligibility check;
- every operator shares one audit actor;
- every caller shares one idempotency namespace. Two jobs passing the same
--idempotency-keyshare one claim, so prefix keys with the job's name.
--tenant is refused with an anonymous or static identity, because there is nothing to
validate it against.
Call an operation¶
$ export FORZE_OPS_APP=myapp.ops:ops
$ forze ops list
$ forze ops describe notes.create
$ forze ops call notes.create --input '{"title": "hi"}'
{"id":"…","title":"hi",…}
Run forze --install-completion once, and your shell completes operation names after
describe and call. Each TAB imports your application module, so keep its import free of
side effects.
--input takes inline JSON, @PATH for a file, or - for stdin. Inline input is refused for
an operation whose input declares a SecretStr or SecretBytes field; pipe it instead. An
unknown input key is refused, not dropped, so a typo in a script fails loudly.
An operation whose input is flat scalars (strings, numbers, booleans, ids, dates, enums) also
takes typed flags instead of --input:
$ forze ops call notes.create --title hi
Each field is a flag named after it, a boolean is --name or --no-name, and the values are
validated exactly as --input is. forze ops describe lists an operation's flags, or says why
it has none; the reference has the rules. Flags put every
value on argv, where ps and shell history show it: secret fields are refused, and any other
sensitive value belongs in --input @PATH.
call writes exactly one JSON document to stdout: the result, or an error object carrying the
invocation's execution_id and correlation_id. Everything else the application writes,
logs and prints included, goes to stderr. Branch on the exit code: 0 is success, 4 a rejected
input, 5 a denial, 75 a failure safe to retry as is. The
reference has the whole table.
For a command a retry may repeat, pass --idempotency-key. A repeat with the same key and
input returns the stored result; the same key with different input exits 4. An operation that
does not support a key refuses the option, so a script never believes a retry is safe when it
is not.
Run a backfill¶
--batch calls an operation once per line of a JSONL file, or of stdin, inside one runtime,
so a backfill pays for startup once rather than per row:
$ forze ops call notes.create --batch @notes.jsonl > results.jsonl
Every line is validated before anything runs, so a typo on line 900 does not leave 899 rows
applied. Stdout gets one JSON line per call, numbered by its input line, and the batch stops at
the first failure: that line is the last one written, and its exit code is the batch's. To
resume, fix that line and run the file from it on. --idempotency-key is refused with
--batch, because one key would claim every line. The
reference has the output contract.
Run it as a Job or from cron¶
Run the command where the application's infrastructure credentials already live, in the cluster, not on a laptop. A Kubernetes Job can retry on exit 75 alone, the code that means "repeating cannot apply anything twice", and fail at once on anything else:
apiVersion: batch/v1
kind: Job
spec:
backoffLimit: 3 # retries of exit 75 count against this
podFailurePolicy:
rules:
- action: FailJob # any other failure is final
onExitCodes: { operator: NotIn, values: [75] }
template:
spec:
restartPolicy: Never
containers:
- name: reconcile
image: myapp:latest
command: ["forze", "ops", "call", "billing.reconcile", "--input", "@/config/reconcile.json"]
env:
- { name: FORZE_OPS_APP, value: myapp.ops:ops }
- name: FORZE_API_KEY
valueFrom: { secretKeyRef: { name: reconcile-job-key, key: api-key } }
A retryable failure of a command called without a key exits 69 instead: a connection lost
during COMMIT may have landed the write. Keep 69 out of automatic retries.
Interrupts¶
The first SIGINT or SIGTERM does not cancel the call. It waits up to --grace seconds (10 by
default) for the call to finish, because cancelling a commit leaves its outcome unknown. A call
that finishes in time exits with its own code. A second signal, or the grace running out,
cancels it:
- before any commit started, the error object's kind is
interrupted, and the exit code is 130 or 143; - at or after a commit point, it is
commit_ambiguous, exit 1. Reconcile before re-running.
Shutdown runs either way. Set the grace below your scheduler's own: Kubernetes'
terminationGracePeriodSeconds defaults to 30.
Ship it inside your own CLI¶
build_ops_cli(ops) returns the same command group with your declaration bound, so
myapp ops call … needs no --app:
import typer
from forze_cli.ops import build_ops_cli
from myapp.ops import ops
cli = typer.Typer(pretty_exceptions_show_locals=False)
cli.add_typer(build_ops_cli(ops))
Never print locals
Set pretty_exceptions_show_locals=False on your root typer.Typer. Typer reads it from
the app you invoke, never from a mounted group, and a crash that prints locals prints the
credentials an operation's frames hold.
The output contract, every option, and the error codes the CLI adds are in the Operations CLI reference.