Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
92 changes: 92 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,98 @@ LOG_LEVEL=INFO
# purpose. Ignored in non-prod (permissive is already the default there).
# ALLOW_PERMISSIVE_AUTHZ=true

# Control substrate: Operation BC Conductor
# CONTROL_WRITES_ENABLED gates every write CORA makes through the
# ControlPort, the Conductor's modeled actuation path. It defaults to
# false: CORA observes every configured route and drives none. A write
# raises ControlWritesDisabledError before any substrate is contacted,
# and the Conductor records it as a step failure.
#
# READ THE SCOPE. This switch covers the ControlPort and nothing else. It
# does NOT cover COMPUTE_SUBSTRATE=local_process, which spawns
# operator-supplied argv as a subprocess and can reach a control system
# without passing through any ControlPort (see the COMPUTE_SUBSTRATE
# block below). Setting CONTROL_WRITES_ENABLED=false does not by itself
# make this host incapable of touching the beamline. See
# docs/stack/deployment.md on observe-only deployments.
#
# Within that scope it is the observe-only safety switch (the APS 2-BM
# pilot posture). It admits no per-substrate or per-route exemption, so a
# config that forgets something fails closed. A deployment that drives
# hardware sets it to true once, on purpose. Local runs and tests that
# exercise writes through Settings must set it too.
# CONTROL_WRITES_ENABLED=true
#
# CONTROL_PORT_ROUTES maps an address prefix to a substrate adapter, as
# JSON. Unset (default) builds an InMemoryControlPort: the conduct surface
# is reachable but no real substrate is exercised. Routes are matched by
# LONGEST prefix, and each prefix must be distinct (a duplicate is refused
# at boot rather than silently discarding the earlier route's flags).
#
# Per-route flags, both declared facts and never inferred from substrate:
# is_simulated this route drives a simulator, even over real Channel
# Access (a soft IOC speaks real CA). Feeds the Dataset
# provenance gate that blocks promoting simulator data.
# read_only CORA may read and subscribe here but never write.
# Per-route expressiveness INSIDE a writable deployment
# ("drive the stage, never the shutter"). It defaults to
# false, so it is NOT how you make a deployment
# observe-only: use CONTROL_WRITES_ENABLED=false for that.
#
# CONTROL_PORT_ROUTES='[
# {"prefix":"2bma:cam1:image","substrate":"epics_pva"},
# {"prefix":"2bma:shutter:","substrate":"epics_ca","read_only":true},
# {"prefix":"2bma:","substrate":"epics_ca"}
# ]'

# Compute substrate: Operation BC conduct runtime
# COMPUTE_SUBSTRATE selects how a conduct job runs. It defaults to
# in_memory, which mints a Simulated result and never spawns anything.
# That default is what keeps this path inert; it is NOT gated by
# CONTROL_WRITES_ENABLED, which covers the ControlPort only.
#
# local_process runs the job's argv as an OS subprocess on this host, as
# the API service account. Understand three things before setting it:
#
# 1. The argv can be caller-supplied. With CORA_ALLOW_RAW_CONDUCT=true
# (the current default) a conduct request for a Method that has no
# launch_spec carries its own `command`, which is executed verbatim.
# 2. No Trust policy gates the spawn. The Authorize port gates the RUN
# TRANSITION (complete_run / abort_run), which happens AFTER the
# subprocess has already run. An authenticated principal holding
# zero permissions can reach it.
# 3. So an argv like ["caput", ...] would reach a control system
# without touching the ControlPort, whatever CONTROL_WRITES_ENABLED
# says. That switch gates the ControlPort only.
#
# COMPUTE_PERMITTED_EXECUTABLES bounds point 3: the substrate refuses any
# command[0] outside it, before spawning. It is EMPTY by default, which
# permits NOTHING, so setting COMPUTE_SUBSTRATE=local_process without
# also setting this yields a port that refuses every job. That is
# deliberate: name what this host may run.
#
# The CHECK matches command[0] exactly: no PATH resolution, no basename
# fallback, so /tmp/evil/tomopy does not ride in on an allowlisted
# "tomopy". Two rules follow, neither optional:
#
# - Allowlist TOOLS, never interpreters. Permitting "python" or "sh"
# re-opens arbitrary execution via -c and the check cannot tell.
# - Declare ABSOLUTE paths. The check is exact, but the spawn still
# PATH-resolves a bare name afterwards, so allowlisting "tomopy"
# permits whatever PATH finds then. A request cannot reach that (the
# conduct body carries no env), but a writable PATH entry on the host
# would still decide what runs.
#
# The allowlist is the belt, not the trousers. It bounds WHAT runs; it
# does not authorize the path. The real fix is to give every compute
# Method a launch_spec (argv then builds server-side from the vetted,
# event-sourced recipe) and set CORA_ALLOW_RAW_CONDUCT=false, which
# deletes the caller-supplied argv path rather than bounding it. See
# docs/stack/deployment.md.
# COMPUTE_SUBSTRATE=local_process
# COMPUTE_PERMITTED_EXECUTABLES='["/opt/conda/envs/tomopy/bin/tomopy"]'
# CORA_ALLOW_RAW_CONDUCT=false

# LLM provider — Agent BC (Phase 8f-b)
# Required if the deployment ships the RunDebrief or CautionDrafter subscribers.
# Without it, the subscribers register but log-and-skip on every Run-terminal event.
Expand Down
19 changes: 19 additions & 0 deletions apps/api/.dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# Build context hygiene. The image needs pyproject.toml, uv.lock, src/
# and README.md; everything else only slows the build or risks baking a
# local secret into a layer.
.env
.env.*
!.env.example

tests/
htmlcov/
.coverage
.coverage.*
.pytest_cache/
.ruff_cache/
.mutmut-cache/
**/__pycache__/
**/*.pyc
**/*.parked
.venv/
openapi.json
117 changes: 117 additions & 0 deletions apps/api/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
# CORA API image.
#
# Build from the REPO ROOT, not from apps/api, so the build context can
# reach nothing it does not need:
#
# docker build -f apps/api/Dockerfile -t cora-api:dev apps/api
#
# ## Why linux/amd64 is pinned and not negotiable
#
# The EPICS client libraries ship binary wheels for x86_64 ONLY. Checked
# against this repo's uv.lock: epicscorelibs, pvxslibs and p4p each
# publish 4 linux wheels, every one of them *-manylinux*_x86_64.whl.
# There is no linux/aarch64 wheel for any of them. On an arm64 host
# (an Apple Silicon laptop, a Graviton runner) an unpinned build either
# fails to resolve or silently tries to compile EPICS base from source.
#
# So the platform is pinned here rather than left to the builder's
# architecture. On arm64 this build runs under emulation and is slow;
# that is the cost of the wheels being what they are, and a slow correct
# build beats a fast one that cannot talk to an IOC.
#
# ## Why the builder needs a C toolchain
#
# aioca publishes NO linux wheel at all (uv.lock lists a single sdist and
# zero wheels), so it compiles its Channel Access extension against
# epicscorelibs via setuptools-dso at install time. That needs gcc/g++.
# The compiler stays in the builder stage and never reaches the runtime
# image.
#
# ## What this image does NOT do
#
# It does not run migrations. Atlas applies them out of band (`make
# migrate-apply`) using a Go binary that is not a Python dependency and
# is deliberately not installed here: migrations are forward-only and
# operator-sequenced, so an image that migrated itself on boot would let
# a rolling deploy race two schema versions against one database.
#
# It carries no default configuration. `app = create_app()` runs at
# IMPORT, and its boot gates raise there, so a misconfigured container
# exits immediately instead of serving. That is the intended behaviour:
# see docs/stack/deployment.md.

ARG PYTHON_VERSION=3.13.12
# Carried as an ARG rather than a literal so buildkit does not read it as
# a multi-arch mistake, and so a future arm64 EPICS wheel can be tried
# with --build-arg. The default is the only value known to work.
ARG BUILD_PLATFORM=linux/amd64

# ---------------------------------------------------------------- builder
FROM --platform=${BUILD_PLATFORM} python:${PYTHON_VERSION}-slim-bookworm AS builder

# gcc/g++ are here for aioca's sdist (see header). They are NOT copied
# into the runtime stage.
RUN apt-get update \
&& apt-get install --no-install-recommends -y gcc g++ \
&& rm -rf /var/lib/apt/lists/*

COPY --from=ghcr.io/astral-sh/uv:0.9.29 /uv /usr/local/bin/uv

ENV UV_COMPILE_BYTECODE=1 \
UV_LINK_MODE=copy \
UV_PYTHON_DOWNLOADS=never

WORKDIR /app

# Dependencies resolve from the committed lock and land in their own
# layer, so a source edit does not trigger an EPICS recompile.
# --frozen: fail rather than silently re-resolve. --no-dev: the dev group
# carries pytest + the tooling. --no-install-project: the project itself
# lands in the next layer.
COPY pyproject.toml uv.lock ./
RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --frozen --no-dev --no-install-project

COPY src ./src
COPY README.md ./
RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --frozen --no-dev

# ---------------------------------------------------------------- runtime
ARG PYTHON_VERSION
ARG BUILD_PLATFORM
FROM --platform=${BUILD_PLATFORM} python:${PYTHON_VERSION}-slim-bookworm AS runtime

# curl backs the HEALTHCHECK below. Drop both together if the
# orchestrator does its own probing.
RUN apt-get update \
&& apt-get install --no-install-recommends -y curl \
&& rm -rf /var/lib/apt/lists/* \
&& useradd --create-home --uid 1000 cora

WORKDIR /app

COPY --from=builder --chown=cora:cora /app/.venv /app/.venv
COPY --from=builder --chown=cora:cora /app/src /app/src

ENV PATH="/app/.venv/bin:$PATH" \
PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1

USER cora

EXPOSE 8000

# Liveness only. /health checks nothing by design; /readyz reports
# Postgres and belongs on the orchestrator's readinessProbe, not here,
# because Docker's HEALTHCHECK failure restarts the container, and
# restarting an app because its database is down is how one outage
# becomes a crash loop. See docs/stack/deployment.md.
HEALTHCHECK --interval=30s --timeout=3s --start-period=20s --retries=3 \
CMD curl -fsS http://localhost:8000/health || exit 1

# No --reload, no --workers. One process per container: the lifespan
# starts in-process background workers (projection worker, subscribers,
# watchers), and --workers would fork N of each against one database.
# Scale with replicas.
CMD ["uvicorn", "cora.api.main:app", "--host", "0.0.0.0", "--port", "8000"]
2 changes: 1 addition & 1 deletion apps/api/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -34266,7 +34266,7 @@
},
"/health": {
"get": {
"description": "Liveness probe.",
"description": "Liveness probe.\n\nChecks nothing, and must keep checking nothing. Every dependency\nit could check is one a restart cannot fix, and CORA is a worse\ncase than most: there is no boot retry, so a DB-checking liveness\nprobe would restart a pod into a DB outage that the restart\ncannot mend, turning one outage into a crash loop. Readiness is\nthe probe that reports dependencies; see `/readyz`.",
"operationId": "health_health_get",
"responses": {
"200": {
Expand Down
33 changes: 28 additions & 5 deletions apps/api/src/cora/api/_conduct_run_route.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,36 @@
(no import coupling), mirroring how `conduct_procedure`'s route reads
its handler off `app.state.operation`.

## Caller-supplied job, Authorize-gated
## Caller-supplied job, and what actually gates it

Like `conduct_procedure` (which accepts a caller-supplied step list of
control addresses + values), this accepts a caller-supplied job spec.
The security boundary is the Authorize port + principal on the
underlying `complete_run` / `abort_run` commands, not a restriction on
the payload shape.
control addresses + values), this accepts a caller-supplied job spec,
with no restriction on the payload shape beyond argv length.

Be precise about the boundary, because the obvious reading is wrong.
The Authorize port gates `complete_run` / `abort_run`, and those run
only AFTER the job reaches a terminal state: `EdgeConductor` submits to
the ComputePort first (`_edge_conductor.py`, `start=None` means there is
no pre-submit seam). So the Authorize port gates the RUN TRANSITION, not
the submit. Under `COMPUTE_SUBSTRATE=local_process` the argv reaches
`create_subprocess_exec` before any Trust policy is consulted, and a
denied `complete_run` does not unspawn the process. The gates that do
apply to the submit are edge AUTHENTICATION, `CORA_ALLOW_RAW_CONDUCT`
(default True; a Method WITH a `launch_spec` always builds argv
server-side and refuses a raw command regardless),
`COMPUTE_PERMITTED_EXECUTABLES` (the local-process substrate refuses any
`command[0]` outside it, before spawning; empty by default, and empty
permits nothing), and the choice of compute substrate itself.

Note what that list does NOT contain: a Trust policy. The allowlist
bounds WHICH executable runs, not WHO may run it. Every gate above is
authentication or configuration.

This matters for observe-only deployments: `COMPUTE_SUBSTRATE` defaults
to `in_memory`, which never spawns anything, and that default is what
keeps this path inert. It is NOT covered by `CONTROL_WRITES_ENABLED`,
which gates the ControlPort only. Before setting `local_process` at a
beamline, read `docs/stack/deployment.md` on observe-only deployments.

## Response code: always 200, failures in body

Expand Down
Loading
Loading