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
9 changes: 9 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,15 @@ inline as f-string heredocs in `simulation_service.py` (one for simulation runs,
The container definition is generated by `pbest`, hashed (`get_singularity_hash`, md5 of the def file), and that hash
is the identity of a `SimulatorVersion` — an unseen hash triggers a container build job before the simulation runs.

**Registry and submission checks.** `compose_api/registry/manifest.yaml` is the registry of record (strategy
decision 5): pinned entries, each with `module_roots`, a curation `level` and `evidence`. `/simulation/run` calls
`registry.validation.validate_submission` before anything is recorded: it opens every composite document in the
upload (nested bundle archives included) and refuses any process address that is not `local:<module>.<Class>` under
a registered entry at level `tested` or higher in the `registry_env` bundle, with a 400 listing each address.
Non-`local` protocols and the arbitrary-import `local:!` form are refused even under `address_policy=warn`. The
container would otherwise run whatever it is sent, so this is the only check. Adding a simulator to what the service
runs means a manifest entry *and* the library in `simulation/simulator_registry.json`.

**Job tracking.** An `HpcRun` row links a SLURM job id, a `correlation_id`, and a `JobType`
(`SIMULATION` / `BUILD_CONTAINER`). `JobMonitor` updates status two ways: a 5-second polling loop reconciling
`squeue`/`sacct` against running `HpcRun` rows, and (only when `hpc_has_messaging` is true) a NATS subscription on
Expand Down
54 changes: 36 additions & 18 deletions compose_api/api/routers/simulation.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,18 @@
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, UploadFile

from compose_api.common.gateway.models import RouterConfig
from compose_api.common.gateway.utils import allow_list, get_simulation_request_from_uploaded_file
from compose_api.common.gateway.utils import get_simulation_request_from_uploaded_file
from compose_api.config import get_settings
from compose_api.dependencies import (
get_database_service,
get_job_monitor,
get_simulation_service,
)
from compose_api.registry.validation import AddressPolicy, SubmissionRejectedError, validate_submission
from compose_api.simulation.handlers import (
run_simulation,
)
from compose_api.simulation.models import (
PBAllowList,
SimulationExperiment,
)
from compose_api.simulation.prebuilt import UnknownSimulatorError, prebuilt_image
Expand All @@ -25,6 +26,12 @@

config = RouterConfig(router=APIRouter(), prefix="/simulation", dependencies=[])

REJECTED_RESPONSE = {
"description": "The submission cannot run. For a problem with the request itself (empty file, unsupported type, "
"interval out of range) `detail` is a string. For a problem with its contents (unreadable archive, or processes "
"the registry does not allow) `detail` is an object with a `message` and a `violations` list naming each address."
}


@config.router.post(
path="/run",
Expand All @@ -33,6 +40,7 @@
tags=["Simulation"],
dependencies=[Depends(get_simulation_service), Depends(get_database_service)],
summary="Run a simulation",
responses={400: REJECTED_RESPONSE},
)
async def submit_simulation(
background_tasks: BackgroundTasks,
Expand All @@ -48,6 +56,32 @@ async def submit_simulation(
prebuilt_image(simulator)
except UnknownSimulatorError as e:
raise HTTPException(status_code=400, detail=str(e)) from e
if interval_time < 0 or interval_time > 1000:
raise HTTPException(status_code=400, detail="Invalid interval time, it has to be between 0 and 1000")

simulation_request = await get_simulation_request_from_uploaded_file(
uploaded_file=uploaded_file, batch_submission=batch_submission
)
simulation_request.end_time_point = interval_time
simulation_request.simulator = simulator
# Checked before anything is recorded or submitted: the container resolves addresses itself, and will import
# whatever module it is told to, so this is the only point a submission is checked. A prebuilt simulator's
# image brings its own processes, which this registry does not describe, so its documents are not checked here:
# the deployment vouches for the image by listing it (settings.prebuilt_simulators).
if simulator is None:
try:
validate_submission(
simulation_request.request_file_path,
simulation_request.simulation_file_type,
AddressPolicy(get_settings().address_policy),
)
except SubmissionRejectedError as e:
simulation_request.request_file_path.unlink(missing_ok=True)
raise HTTPException(
status_code=400,
detail={"message": e.message, "violations": [v.model_dump() for v in e.violations]},
) from e

sim_service = get_simulation_service()
if sim_service is None:
logger.error("Simulation service is not initialized")
Expand All @@ -61,29 +95,13 @@ async def submit_simulation(
logger.error("Job Monitor service is not initialized")
raise HTTPException(status_code=500, detail="Job Monitor service is not initialized")

# TODO ################################################################################
# !!! Input validation for Omex file, preferably converting it to an internal type !!!#
#######################################################################################
logger.warning("NO VALIDATION YET")
# Tmp file for future implementation
if interval_time < 0 or interval_time > 1000:
raise HTTPException(status_code=400, detail="Invalid interval time, it has to be between 0 and 1000")

simulation_request = await get_simulation_request_from_uploaded_file(
uploaded_file=uploaded_file, batch_submission=batch_submission
)
simulation_request.end_time_point = interval_time
simulation_request.simulator = simulator

try:
return await run_simulation(
simulation_request=simulation_request,
database_service=db_service,
simulation_service_slurm=sim_service,
background_tasks=background_tasks,
job_monitor=job_monitor,
# TODO: Put/Get actual allow list
pb_allow_list=PBAllowList(allow_list=allow_list),
)
except Exception as e:
logger.exception("Error running simulation")
Expand Down
6 changes: 6 additions & 0 deletions compose_api/api/spec/openapi_3_1_0_generated.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,12 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/SimulationExperiment'
'400':
description: The submission cannot run. For a problem with the request itself
(empty file, unsupported type, interval out of range) `detail` is a string.
For a problem with its contents (unreadable archive, or processes the
registry does not allow) `detail` is an object with a `message` and a
`violations` list naming each address.
'422':
description: Validation Error
content:
Expand Down
22 changes: 7 additions & 15 deletions compose_api/common/gateway/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,25 +55,17 @@ async def get_simulation_request_from_uploaded_file(
raise HTTPException(status_code=400, detail="Empty uploaded file")

suffix = Path(uploaded_file.filename).suffix
try:
file_type = SimulationFileType.get_file_type(suffix)
except ValueError as e:
raise HTTPException(
status_code=400, detail=f"Unsupported file type `{suffix}`; send .omex, .pbg or .sbml"
) from e
with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as tmp_file:
contents = await uploaded_file.read()
tmp_file.write(contents)
return SimulationRequest(
request_file_path=Path(tmp_file.name),
simulation_file_type=SimulationFileType.get_file_type(suffix),
simulation_file_type=file_type,
is_batch=batch_submission,
)


allow_list = [
"pypi::git+https://github.com/biosimulators/bspil-basico.git@initial_work",
"pypi::cobra",
"pypi::tellurium",
"pypi::copasi-basico",
"pypi::smoldyn",
"pypi::numpy",
"pypi::matplotlib",
"pypi::scipy",
"pypi::pb_multiscale_actin",
"conda::readdy",
]
6 changes: 6 additions & 0 deletions compose_api/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,12 @@ class Settings(BaseSettings):
# behaviour when a download fails, not an error.
simulator_image_repository: str = "ghcr.io/biosimulations/registry_env"

# What /simulation/run does with a process address the registry manifest does not allow
# (compose_api/registry/manifest.yaml). "enforce" rejects the submission with a 400; "warn" logs and runs it,
# as an escape hatch if enforcement turns out to block a real user. Unsafe addresses -- non-local protocols
# and the arbitrary-import `local:!` form -- are rejected under either policy.
address_policy: Literal["enforce", "warn"] = "enforce"

# Simulators published by their owners as OCI images, by name: a request's `simulator`
# query parameter selects one and the job runs in that image instead of the shared one
# (compose_api/simulation/prebuilt.py). Only these names are accepted, so a request can
Expand Down
Empty file.
82 changes: 82 additions & 0 deletions compose_api/registry/manifest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
"""The simulator registry manifest: the components this service will run, and how far each has been checked.

The manifest is a versioned file in this package, reviewed by pull request, and read at call time. It is the
registry of record described by strategy decision 5; the database tables that predate it are not consulted.
"""

import enum
from pathlib import Path

import yaml
from pydantic import BaseModel, Field, model_validator

_MANIFEST_PATH = Path(__file__).parent / "manifest.yaml"

# The bundle the service currently runs every composite in. See strategy decision 5, "registry_env is the first
# bundle, and a stopgap".
SERVICE_BUNDLE = "registry_env"


class CurationLevel(enum.StrEnum):
"""The curation ladder, lowest first. Order matters: comparisons use the declaration order."""

LISTED = "listed"
INSTALLABLE = "installable"
TESTED = "tested"
VALIDATED = "validated"
CURATED = "curated"

@property
def rank(self) -> int:
return list(CurationLevel).index(self)

def at_least(self, other: "CurationLevel") -> bool:
return self.rank >= other.rank


# The lowest level the service will run on HPC (goal G6).
HPC_MINIMUM_LEVEL = CurationLevel.TESTED


class RegistryEntry(BaseModel):
id: str
package: str
source: str
version: str | None = None
commit: str | None = None
module_roots: list[str] = Field(min_length=1)
level: CurationLevel
bundles: list[str] = Field(default_factory=list)
evidence: list[str] = Field(default_factory=list)
reviewer: str | None = None

@model_validator(mode="after")
def _pinned(self) -> "RegistryEntry":
if self.version is None and self.commit is None:
raise ValueError(f"registry entry {self.id!r} must be pinned by version or commit")
return self


class Manifest(BaseModel):
entries: list[RegistryEntry]

@model_validator(mode="after")
def _unique_ids(self) -> "Manifest":
ids = [entry.id for entry in self.entries]
duplicates = sorted({i for i in ids if ids.count(i) > 1})
if duplicates:
raise ValueError(f"duplicate registry entry ids: {duplicates}")
return self

def entry_for_module(self, module: str) -> RegistryEntry | None:
"""The entry whose longest module root contains `module`, so a nested root wins over its parent."""
best: tuple[int, RegistryEntry] | None = None
for entry in self.entries:
for root in entry.module_roots:
if (module == root or module.startswith(root + ".")) and (best is None or len(root) > best[0]):
best = (len(root), entry)
return None if best is None else best[1]


def load_manifest(path: Path = _MANIFEST_PATH) -> Manifest:
return Manifest.model_validate(yaml.safe_load(path.read_text()))
39 changes: 39 additions & 0 deletions compose_api/registry/manifest.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# The simulator registry of record: which process-bigraph components this service will run, pinned, and how far
# each has been checked. Strategy decision 5 (docs/strategy.md) defines the fields and the curation ladder.
#
# Changes land by pull request. Raising an entry's level needs the evidence for the new level in `evidence`.
# An address is accepted on HPC only if its module sits under one of an entry's `module_roots`, that entry is at
# level `tested` or higher, and the entry is part of the bundle the service runs (`registry_env`).

entries:
- id: pbsim-common
package: pbsim-common
source: https://github.com/biosimulations/biosim-registry
version: "0.1.2"
module_roots:
- pbsim_common
level: tested
bundles:
- registry_env
evidence:
- >-
Grandfathered at tested on 2026-09-23. The gate is this repository's own cluster tests
(tests/simulators/test_copasi.py, tests/simulators/test_tellurium.py, and the phase_cycle.omex fixture),
which run CopasiUTCStep and TelluriumUTCStep inside the built registry_env image, not the package's own
test suite. Phase B of decision 5 replaces this with a computed level.

- id: pb-multiscale-actin
package: pb-multiscale-actin
source: https://pypi.org/project/pb-multiscale-actin/1.3.1/
version: "1.3.1"
module_roots:
- pb_multiscale_actin
level: tested
bundles:
- registry_env
evidence:
- >-
Grandfathered at tested on 2026-09-23. The gate is this repository's own cluster test
(tests/simulators/test_readdy.py with the readdy.omex fixture), which runs ReaddyActinMembrane and
SimulariumEmitter inside the built registry_env image, not the package's own test suite. Phase B of
decision 5 replaces this with a computed level.
Loading
Loading