diff --git a/.deepsource.toml b/.deepsource.toml
new file mode 100644
index 00000000..01a10663
--- /dev/null
+++ b/.deepsource.toml
@@ -0,0 +1,9 @@
+version = 1
+
+[[analyzers]]
+name = "python"
+enabled = true
+
+ [analyzers.meta]
+ runtime_version = "3.x.x"
+ max_line_length = 200
diff --git a/.github/workflows/api-validation.yml b/.github/workflows/api-validation.yml
new file mode 100644
index 00000000..adfcd92a
--- /dev/null
+++ b/.github/workflows/api-validation.yml
@@ -0,0 +1,132 @@
+name: API Validation with Schemathesis
+
+on:
+ pull_request:
+ push:
+ branches: [ main ]
+
+jobs:
+ schemathesis:
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v4
+ with:
+ token: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Checkout schema validator repository
+ uses: actions/checkout@v4
+ with:
+ repository: doe-iri/iri-facility-api-docs
+ ref: main
+ path: schema-validator
+ token: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Set up Python
+ uses: actions/setup-python@v5
+ with:
+ python-version: "3.13"
+
+ - name: Install uv
+ run: pip install uv
+
+ - name: Build an image
+ run: docker build --platform=linux/amd64 -t iri-facility-api-base .
+
+ - name: Run Facility API container
+ run: |
+ docker run -d \
+ -p 8000:8000 \
+ --platform=linux/amd64 \
+ --name iri-facility-api-base \
+ -e IRI_API_ADAPTER_facility=app.demo_adapter.DemoAdapter \
+ -e IRI_API_ADAPTER_status=app.demo_adapter.DemoAdapter \
+ -e IRI_API_ADAPTER_account=app.demo_adapter.DemoAdapter \
+ -e IRI_API_ADAPTER_compute=app.demo_adapter.DemoAdapter \
+ -e IRI_API_ADAPTER_filesystem=app.demo_adapter.DemoAdapter \
+ -e IRI_API_ADAPTER_task=app.demo_adapter.DemoAdapter \
+ -e API_URL_ROOT=http://127.0.0.1:8000 \
+ -e IRI_API_TOKEN=12345 \
+ iri-facility-api-base
+
+ - name: Wait for API to be ready
+ run: |
+ for i in {1..60}; do
+ if curl -fs http://127.0.0.1:8000/openapi.json; then
+ echo "API ready"
+ exit 0
+ fi
+ sleep 2
+ done
+ echo "API did not start"
+ exit 1
+
+ - name: Create venv & install validator dependencies
+ run: |
+ uv venv
+ source .venv/bin/activate
+ uv pip install -r schema-validator/verification/requirements.txt
+
+ - name: Run Schemathesis validation (local spec)
+ id: schemathesis_local
+ env:
+ IRI_API_TOKEN: "12345" # This is dummy token for testing (mock adapter)
+ run: |
+ set +e
+ source .venv/bin/activate
+ python schema-validator/verification/api-validator.py \
+ --baseurl http://127.0.0.1:8000 \
+ --report-name schemathesis-local
+ echo "exitcode=$?" >> $GITHUB_OUTPUT
+
+ - name: Run Schemathesis validation (official spec)
+ id: schemathesis_official
+ env:
+ IRI_API_TOKEN: "12345"
+ run: |
+ set +e
+ source .venv/bin/activate
+ python schema-validator/verification/api-validator.py \
+ --baseurl http://localhost:8000 \
+ --schema-url https://raw.githubusercontent.com/doe-iri/iri-facility-api-docs/refs/heads/main/specification/openapi/all_spec.yaml \
+ --report-name schemathesis-official
+ echo "exitcode=$?" >> $GITHUB_OUTPUT
+
+ - name: Fail if any Schemathesis run failed
+ if: always()
+ run: |
+ if [ "${{ steps.schemathesis_local.outputs.exitcode }}" != "0" ] || \
+ [ "${{ steps.schemathesis_official.outputs.exitcode }}" != "0" ]; then
+ echo "One or more Schemathesis validations failed"
+ exit 1
+ else
+ echo "Both Schemathesis validations passed"
+ fi
+
+ - name: Upload Schemathesis report # This only works on git actions
+ if: always() && env.ACT != 'true'
+ uses: actions/upload-artifact@v4
+ with:
+ if-no-files-found: warn
+ name: schemathesis-report
+ path: |
+ schemathesis-local.html
+ schemathesis-local.xml
+ schemathesis-official.html
+ schemathesis-official.xml
+
+ - name: Save Schemathesis reports locally # This only works if run locally with act
+ if: always() && env.ACT == 'true'
+ run: |
+ mkdir -p artifacts
+ cp schemathesis-local.html schemathesis-local.xml artifacts/ || true
+ cp schemathesis-official.html schemathesis-official.xml artifacts/ || true
+
+ - name: Dump API logs
+ if: always()
+ run: docker logs iri-facility-api-base || true
+
+ - name: Stop container
+ if: always()
+ run: docker stop iri-facility-api-base || true
diff --git a/.gitignore b/.gitignore
index 56f3db02..a3786635 100644
--- a/.gitignore
+++ b/.gitignore
@@ -6,8 +6,6 @@ iri_sandbox
.env
uv.lock
docs/
-Makefile
-pyproject.toml
.DS_Store
data/
app/s3df/coact-models.py
@@ -16,4 +14,5 @@ app/s3df/clients/IMPLEMENTATION.md
set_env.sh
test_compute.sh
fastapi.log
-compute-tests/
\ No newline at end of file
+compute-tests/
+local.env
diff --git a/Dockerfile b/Dockerfile
index 0130d755..15fa10d0 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,4 +1,4 @@
-FROM python:3.13-slim
+FROM python:3.13
RUN mkdir /app
COPY . /app
@@ -14,4 +14,3 @@ ENV DEX_ISSUER="https://dex.slac.stanford.edu"
CMD ["fastapi", "run", "app/main.py", "--port", "8000"]
-
diff --git a/Makefile b/Makefile
index 697cad6c..98caa55e 100644
--- a/Makefile
+++ b/Makefile
@@ -1,47 +1,104 @@
-dev : .venv
- @source ./.venv/bin/activate && \
- IRI_API_ADAPTER_status=app.demo_adapter.DemoAdapter \
- IRI_API_ADAPTER_account=app.demo_adapter.DemoAdapter \
- IRI_API_ADAPTER_compute=app.demo_adapter.DemoAdapter \
- IRI_API_ADAPTER_filesystem=app.demo_adapter.DemoAdapter \
- IRI_API_ADAPTER_task=app.demo_adapter.DemoAdapter \
- API_URL_ROOT='http://127.0.0.1:8000' fastapi dev
+PYTHON := python3.13
+VENV := .venv
+BIN := $(VENV)/bin
+UV := uv
+PIP := $(BIN)/pip
+STAMP_VENV := $(VENV)/.created
+STAMP_DEPS := $(VENV)/.deps
-dev-s3df : .venv
- @source ./.venv/bin/activate && \
- IRI_API_ADAPTER_account=app.s3df.account_adapter.S3DFAccountAdapter \
- COACT_API_URL='https://coact-dev.slac.stanford.edu/graphql-service-dev' \
- IRI_SHOW_MISSING_ROUTES='true' \
- API_URL_ROOT='http://127.0.0.1:8000' fastapi dev
+.DEFAULT_GOAL := dev
+$(STAMP_VENV):
+ $(UV) venv $(VENV)
+ touch $(STAMP_VENV)
+
+.venv: $(STAMP_VENV)
+
+$(STAMP_DEPS): $(STAMP_VENV) pyproject.toml
+ $(UV) pip install --python $(BIN)/python -e .
+ $(UV) pip install --python $(BIN)/python \
+ ruff \
+ pylint \
+ bandit
+ touch $(STAMP_DEPS)
+
+deps: $(STAMP_DEPS)
+
+dev: deps
+ @source $(BIN)/activate && \
+ [ -f local.env ] && source local.env || true && \
+ IRI_API_ADAPTER_facility=app.demo_adapter.DemoAdapter \
+ IRI_API_ADAPTER_status=app.demo_adapter.DemoAdapter \
+ IRI_API_ADAPTER_account=app.demo_adapter.DemoAdapter \
+ IRI_API_ADAPTER_compute=app.demo_adapter.DemoAdapter \
+ IRI_API_ADAPTER_filesystem=app.demo_adapter.DemoAdapter \
+ IRI_API_ADAPTER_task=app.demo_adapter.DemoAdapter \
+ DEMO_QUEUE_UPDATE_SECS=2 \
+ OPENTELEMETRY_ENABLED=true \
+ API_URL_ROOT='http://localhost:8000' fastapi dev
+
+dev-s3df: deps
+ @source $(BIN)/activate && \
+ IRI_API_ADAPTER_account=app.s3df.account_adapter.S3DFAccountAdapter \
+ COACT_API_URL='https://coact-dev.slac.stanford.edu/graphql-service-dev' \
+ IRI_SHOW_MISSING_ROUTES='true' \
+ API_URL_ROOT='http://127.0.0.1:8000' fastapi dev
# --- Docker / GHCR targets ---
GHCR_USERNAME ?= ""
GHCR_IMAGE ?= ghcr.io/$(GHCR_USERNAME)/iri-s3df
IMAGE_TAG ?= dev
-# build for linux/amd64 (for now, since coact client only works on linux)
docker-build:
docker build --platform linux/amd64 -t $(GHCR_IMAGE):$(IMAGE_TAG) .
docker-push: docker-build
docker push $(GHCR_IMAGE):$(IMAGE_TAG)
-# Test coact client locally inside the container (needs password)
docker-test-coact:
@docker run --rm -it \
-e COACT_SERVICE_PASSWORD \
$(GHCR_IMAGE):$(IMAGE_TAG) \
python -m app.s3df.clients.example
+.PHONY: clean
+clean:
+ rm -rf iri_sandbox
+ rm -rf .venv
-.venv:
- @uv venv
- @uv pip install -e .
+# Format and lint
+format: deps
+ $(BIN)/ruff format --line-length 200 .
+ruff: deps
+ $(BIN)/ruff check . --fix || true
-.PHONY: clean
-clean:
- @rm -rf iri_sandbox
- @rm -rf .venv
+pylint: deps
+ find . -path ./$(VENV) -prune -o -type f -name "*.py" -print0 | while IFS= read -r -d '' f; do \
+ echo "Pylint $$f"; \
+ $(BIN)/pylint $$f --rcfile pylintrc || true; \
+ done
+
+# Security
+audit: deps
+ uv pip compile pyproject.toml -o requirements.txt
+ uv pip sync requirements.txt
+ uv pip install pip-audit
+ $(BIN)/pip-audit || true
+ rm -f requirements.txt
+
+bandit: deps
+ $(BIN)/bandit -r app || true
+
+# Full validation bundle
+lint: clean format ruff pylint audit bandit
+
+globus: deps
+ @source local.env && $(BIN)/python ./tools/globus.py
+
+ARGS ?=
+
+# call it via: make manage-globus ARGS=scopes-show
+manage-globus: deps
+ @source local.env && $(BIN)/python ./tools/manage_globus.py $(ARGS)
diff --git a/README.md b/README.md
index 5ef41a6a..b47226df 100644
--- a/README.md
+++ b/README.md
@@ -5,7 +5,7 @@ See it live:
- NERSC instance:
- API docs: https://api.iri.nersc.gov
- - API requests: https://api.iri.nersc.gov/nersc/api/v1/
+ - API requests: https://api.iri.nersc.gov/api/v1/
- ALCF instance:
- API docs: https://api.alcf.anl.gov
- API requests: https://api.alcf.anl.gov/api/v1/
@@ -51,6 +51,8 @@ If using docker (see next section), your dockerfile could extend this reference
- `API_URL_ROOT`: the base url when constructing links returned by the api (eg.: https://iri.myfacility.com)
- `API_PREFIX`: the path prefix where the api is hosted. Defaults to `/`. (eg.: `/api`)
- `API_URL`: the path to the api itself. Defaults to `api/v1`.
+- `OPENTELEMETRY_ENABLED`: Enables OpenTelemetry. If enabled, the application will use OpenTelemetry SDKs and emit traces, metrics, and logs. Default to false
+- `OTLP_ENDPOINT`: OpenTelemetry Protocol collector endpoint to export telemetry data. If empty or not set, telemetry data is logged locally to log file. Default: ""
Links to data, created by this api, will concatenate these values producing links, eg: `https://iri.myfacility.com/my_api_prefix/my_api_url/projects/123`
@@ -119,11 +121,25 @@ ENV IRI_API_PARAMS='{ \
}'
```
+## Globus auth integration
+
+You can optionally use globus for authorization. Steps to use globus:
+- ask someone to add your globus account to the IRI Resource Server
+- log into globus and make a secret for yourself for the IRI Resource Server
+- if you want to create tokens during developent, also create a separate globus app
+- `cp local-template.env local.env` and fill in the missing values
+- to mint a token, run `make globus`, click the link and copy the code from the browser url bar back into the terminal
+- you can also run `make manage-globus` but be sure to not accidentally delete the `iri-api` scope. (Maybe it's better if you don't run this app)
+- now you can run `make` for the dev server and enjoy using your globus iri access tokens (in the demo adapter they will all resolve to the user `gtorok`)
+- for your facility:
+ - implement the `get_current_user_globus` method (see iri_adapter.py). Here you can look at the linked globus identities and session info to determine what the local username is
+ - make sure the values in `local.env` are available in the deployed app
+
## Next steps
- Learn more about [fastapi](https://fastapi.tiangolo.com/), including how to run it [in production](https://fastapi.tiangolo.com/advanced/behind-a-proxy/)
-- Instead of the simulated state, keep real data in a [database](/Users/gtorok/dev/iri-api-python/README.md)
-- Add monitoring by [integrating with OpenTelemetry](https://opentelemetry.io/docs/zero-code/python/)
+- Instead of the simulated state, keep real data in a database
+- Specify the monitoring endpoint by setting the [OpenTelemetry](https://opentelemetry.io/docs/zero-code/python/) env vars
- Add additional routers for other API-s
- Add authenticated API-s via an [OAuth2 integration](https://fastapi.tiangolo.com/tutorial/security/oauth2-jwt/)
diff --git a/app/__init__.py b/app/__init__.py
index 39fe7626..b36383a6 100644
--- a/app/__init__.py
+++ b/app/__init__.py
@@ -1,2 +1,3 @@
-from pkgutil import extend_path
-__path__ = extend_path(__path__, __name__)
+from pkgutil import extend_path
+
+__path__ = extend_path(__path__, __name__)
diff --git a/app/apilogger.py b/app/apilogger.py
new file mode 100644
index 00000000..80403a9f
--- /dev/null
+++ b/app/apilogger.py
@@ -0,0 +1,28 @@
+"""Logging utilities for the IRI Facility API."""
+import logging
+
+LEVELS = {"FATAL": logging.FATAL,
+ "ERROR": logging.ERROR,
+ "WARNING": logging.WARNING,
+ "INFO": logging.INFO,
+ "DEBUG": logging.DEBUG}
+
+
+def get_stream_logger(name: str = __name__, level: str = "DEBUG") -> logging.Logger:
+ """
+ Return a configured Stream logger.
+ """
+ logger = logging.getLogger(name)
+
+ if not logger.handlers:
+ handler = logging.StreamHandler()
+
+ formatter = logging.Formatter("%(asctime)s.%(msecs)03d - %(name)s - %(levelname)s - %(message)s", datefmt="%a, %d %b %Y %H:%M:%S")
+
+ handler.setFormatter(formatter)
+ logger.addHandler(handler)
+
+ logger.setLevel(LEVELS.get(level, logging.DEBUG))
+ logger.propagate = False
+
+ return logger
\ No newline at end of file
diff --git a/app/config.py b/app/config.py
index 078b6a52..35c17c93 100644
--- a/app/config.py
+++ b/app/config.py
@@ -1,9 +1,11 @@
+"""Configuration for the IRI Facility API reference implementation."""
import os
-import logging
import json
+from .apilogger import get_stream_logger
-logging.basicConfig()
-logging.getLogger().setLevel(logging.INFO)
+LOG_LEVEL = os.environ.get("LOG_LEVEL", "DEBUG")
+
+logger = get_stream_logger(__name__, LOG_LEVEL)
API_VERSION = "1.0.0"
@@ -23,20 +25,37 @@
"description": description,
"version": API_VERSION,
"docs_url": "/",
- "contact": {
- "name": "Facility API contact",
- "url": "https://www.somefacility.gov/about/contact-us/"
- },
- "terms_of_service": "https://www.somefacility.gov/terms-of-service"
+ "contact": {"name": "Facility API contact", "url": "https://www.somefacility.gov/about/contact-us/"},
+ "terms_of_service": "https://www.somefacility.gov/terms-of-service",
}
try:
# optionally overload the init params
d2 = json.loads(os.environ.get("IRI_API_PARAMS", "{}"))
API_CONFIG.update(d2)
except Exception as exc:
- logging.getLogger().error(f"Error parsing IRI_API_PARAMS: {exc}")
+ logger.error(f"Error parsing IRI_API_PARAMS: {exc}")
API_URL_ROOT = os.environ.get("API_URL_ROOT", "https://api.iri.nersc.gov")
API_PREFIX = os.environ.get("API_PREFIX", "/")
API_URL = os.environ.get("API_URL", "api/v1")
+
+OPENTELEMETRY_ENABLED = os.environ.get("OPENTELEMETRY_ENABLED", "false").lower() == "true"
+OPENTELEMETRY_DEBUG = os.environ.get("OPENTELEMETRY_DEBUG", "false").lower() == "true"
+OTLP_ENDPOINT = os.environ.get("OTLP_ENDPOINT", "")
+OTEL_SAMPLE_RATE = float(os.environ.get("OTEL_SAMPLE_RATE", "0.2"))
+
+# Print all startup config for debugging
+logger.info("IRI Facility API starting with config:")
+logger.info("="*40)
+logger.info(f"API_VERSION={API_VERSION}")
+logger.info(f"API_CONFIG={API_CONFIG}")
+logger.info(f"API_URL_ROOT={API_URL_ROOT}")
+logger.info(f"API_PREFIX={API_PREFIX}")
+logger.info(f"API_URL={API_URL}")
+logger.info(f"LOG_LEVEL={LOG_LEVEL}")
+logger.info(f"OPENTELEMETRY_ENABLED={OPENTELEMETRY_ENABLED}")
+logger.info(f"OPENTELEMETRY_DEBUG={OPENTELEMETRY_DEBUG}")
+logger.info(f"OTLP_ENDPOINT={OTLP_ENDPOINT}")
+logger.info(f"OTEL_SAMPLE_RATE={OTEL_SAMPLE_RATE}")
+logger.info("="*40)
diff --git a/app/demo_adapter.py b/app/demo_adapter.py
index 9cbc7961..fa9a23ae 100644
--- a/app/demo_adapter.py
+++ b/app/demo_adapter.py
@@ -1,105 +1,273 @@
+"""
+A demo adapter for the IRI Facility API that returns hardcoded data.
+This is useful for testing and development of the API without needing to connect to real resources
+"""
+import base64
import datetime
-import random
-import uuid
-import time
+import glob
+import grp
import os
-import stat
+import pathlib
import pwd
-import grp
-import glob
+import random
+import stat
import subprocess
-import pathlib
-import base64
-from pydantic import BaseModel
-from typing import Any, Tuple
+import uuid
+
from fastapi import HTTPException
-from .routers.status import models as status_models, facility_adapter as status_adapter
-from .routers.account import models as account_models, facility_adapter as account_adapter
-from .routers.compute import models as compute_models, facility_adapter as compute_adapter
-from .routers.filesystem import models as filesystem_models, facility_adapter as filesystem_adapter
-from .routers.task import models as task_models, facility_adapter as task_adapter
+from fastapi.encoders import jsonable_encoder
+from pydantic import BaseModel
+
+from .routers.account import facility_adapter as account_adapter
+from .routers.account import models as account_models
+from .routers.compute import facility_adapter as compute_adapter
+from .routers.compute import models as compute_models
+from .routers.facility import facility_adapter
+from .routers.facility import models as facility_models
+from .routers.filesystem import facility_adapter as filesystem_adapter
+from .routers.filesystem import models as filesystem_models
+from .routers.status import facility_adapter as status_adapter
+from .routers.status import models as status_models
+from .routers.task import facility_adapter as task_adapter
+from .routers.task import models as task_models
+from .types.models import Capability
+from .types.user import User
+from .types.scalars import AllocationUnit
+from .apilogger import get_stream_logger
+from .config import LOG_LEVEL
+
+logger = get_stream_logger(__name__, LOG_LEVEL)
+
+DEMO_QUEUE_UPDATE_SECS = int(os.environ.get("DEMO_QUEUE_UPDATE_SECS", 5))
+
+
+def paginate_list(items, offset: int | None, limit: int | None):
+ """Return a sliced items using offset and limit."""
+ if offset is not None and offset > 0:
+ items = items[offset:]
+ if limit is not None and limit >= 0:
+ items = items[:limit]
+ return items
+
+class CommandError(RuntimeError):
+ """Raised when an external subprocess command fails."""
+
+ def __init__(self, cmd, returncode=None, stdout=None, stderr=None):
+ self.cmd = cmd
+ self.returncode = returncode
+ self.stdout = stdout
+ self.stderr = stderr
+
+ super().__init__(f"Command failed: {cmd} (rc={returncode})")
-DEMO_QUEUE_UPDATE_SECS = 5
class PathSandbox:
+ """A simple sandbox for file operations."""
_base_temp_dir = None
@classmethod
def get_base_temp_dir(cls):
+ """Get the base temporary directory for the sandbox."""
if cls._base_temp_dir is None:
# Create in system temp with a fixed name
- cls._base_temp_dir = os.path.join(
- os.getcwd(),
- "iri_sandbox"
- )
+ cls._base_temp_dir = os.path.join(os.getcwd(), "iri_sandbox")
os.makedirs(cls._base_temp_dir, exist_ok=True)
# create a test file
- with open(f"{cls._base_temp_dir}/test.txt", "w") as f:
+ with open(f"{cls._base_temp_dir}/test.txt", encoding="utf-8", mode="w") as f:
f.write("hello world")
+ logger.info(f"Created test file in sandbox: {cls._base_temp_dir}/test.txt")
return cls._base_temp_dir
-class DemoAdapter(status_adapter.FacilityAdapter, account_adapter.FacilityAdapter,
- compute_adapter.FacilityAdapter, filesystem_adapter.FacilityAdapter,
- task_adapter.FacilityAdapter):
+def demo_uuid(kind: str, name: str) -> str:
+ """Generate a deterministic UUID based on the kind and name."""
+ return str(uuid.uuid5(uuid.NAMESPACE_DNS, f"demo:{kind}:{name}"))
+
+
+def utc_now() -> datetime.datetime:
+ """Return current UTC datetime timestamp"""
+ return datetime.datetime.now(datetime.timezone.utc)
+
+
+def utc_timestamp() -> int:
+ """Return current UTC datetime timestamp as integer"""
+ return int(utc_now().timestamp())
+
+
+class DemoAdapter(
+ status_adapter.FacilityAdapter, account_adapter.FacilityAdapter, compute_adapter.FacilityAdapter, filesystem_adapter.FacilityAdapter, task_adapter.FacilityAdapter, facility_adapter.FacilityAdapter
+):
+ """A demo implementation of the FacilityAdapter that returns hardcoded data."""
def __init__(self):
self.resources = []
self.incidents = []
self.events = []
self.capabilities = {}
- self.user = account_models.User(id="gtorok", name="Gabor Torok", api_key="12345", client_ip="1.2.3.4")
+ self.user = User(id="gtorok", name="Gabor Torok", api_key="12345", client_ip="1.2.3.4")
self.projects = []
self.project_allocations = []
self.user_allocations = []
-
+ self.facility = {}
+ self.locations = []
+ self.sites = []
self._init_state()
-
def _init_state(self):
- day_ago = datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(days=1)
+ now = utc_now()
+
+ site1 = facility_models.Site(
+ id=demo_uuid("site", "demo_site_1"),
+ name="Demo Site 1",
+ description="The first demo site",
+ last_modified=now,
+ short_name="DS1",
+ operating_organization="Demo Org",
+ country_name="USA",
+ locality_name="Demo City",
+ state_or_province_name="DC",
+ latitude=36.173357,
+ longitude=-234.51452,
+ resource_ids=[],
+ )
+
+ site2 = facility_models.Site(
+ id=demo_uuid("site", "demo_site_2"),
+ name="Demo Site 2",
+ description="The second demo site",
+ last_modified=now,
+ short_name="DS2",
+ operating_organization="Demo Org",
+ country_name="USA",
+ locality_name="Example Town",
+ state_or_province_name="ET",
+ latitude=38.410558,
+ longitude=-286.36999,
+ resource_ids=[],
+ )
+
+ self.facility = facility_models.Facility(
+ id=demo_uuid("facility", "demo_facility"),
+ name="Demo Facility",
+ description="A demo facility for testing the IRI Facility API",
+ last_modified=now,
+ short_name="DEMO",
+ organization_name="Demo Organization",
+ support_uri="https://support.demo.example",
+ site_ids=[site1.id, site2.id],
+ )
+
+ self.sites = [site1, site2]
+
+ day_ago = utc_now() - datetime.timedelta(days=1)
self.capabilities = {
- "cpu": account_models.Capability(id=str(uuid.uuid4()), name="CPU Nodes", units=[account_models.AllocationUnit.node_hours]),
- "gpu": account_models.Capability(id=str(uuid.uuid4()), name="GPU Nodes", units=[account_models.AllocationUnit.node_hours]),
- "hpss": account_models.Capability(id=str(uuid.uuid4()), name="Tape Storage", units=[account_models.AllocationUnit.bytes, account_models.AllocationUnit.inodes]),
- "gpfs": account_models.Capability(id=str(uuid.uuid4()), name="GPFS Storage", units=[account_models.AllocationUnit.bytes, account_models.AllocationUnit.inodes]),
+ "cpu": Capability(id=demo_uuid("capability", "cpu"), name="CPU Nodes", units=[AllocationUnit.node_hours]),
+ "gpu": Capability(id=demo_uuid("capability", "gpu"), name="GPU Nodes", units=[AllocationUnit.node_hours]),
+ "hpss": Capability(id=demo_uuid("capability", "hpss"), name="Tape Storage", units=[AllocationUnit.bytes, AllocationUnit.inodes]),
+ "gpfs": Capability(id=demo_uuid("capability", "gpfs"), name="GPFS Storage", units=[AllocationUnit.bytes, AllocationUnit.inodes]),
}
- pm = status_models.Resource(id=str(uuid.uuid4()), group="perlmutter", name="compute nodes", description="the perlmutter computer compute nodes", capability_ids=[
- self.capabilities["cpu"].id,
- self.capabilities["gpu"].id,
- ], current_status=status_models.Status.degraded, last_modified=day_ago, resource_type=status_models.ResourceType.compute)
- hpss = status_models.Resource(id=str(uuid.uuid4()), group="hpss", name="hpss", description="hpss tape storage", capability_ids=[self.capabilities["hpss"].id], current_status=status_models.Status.up, last_modified=day_ago, resource_type=status_models.ResourceType.storage)
- cfs = status_models.Resource(id=str(uuid.uuid4()), group="cfs", name="cfs", description="cfs storage", capability_ids=[self.capabilities["gpfs"].id], current_status=status_models.Status.up, last_modified=day_ago, resource_type=status_models.ResourceType.storage)
-
- self.resources = [
- pm,
- hpss,
- cfs,
- status_models.Resource(id=str(uuid.uuid4()), group="perlmutter", name="login nodes", description="the perlmutter computer login nodes", capability_ids=[], current_status=status_models.Status.degraded, last_modified=day_ago, resource_type=status_models.ResourceType.system),
- status_models.Resource(id=str(uuid.uuid4()), group="services", name="Iris", description="Iris webapp", capability_ids=[], current_status=status_models.Status.down, last_modified=day_ago, resource_type=status_models.ResourceType.website),
- status_models.Resource(id=str(uuid.uuid4()), group="services", name="sfapi", description="the Superfacility API", capability_ids=[], current_status=status_models.Status.up, last_modified=day_ago, resource_type=status_models.ResourceType.service),
- ]
+ pm = status_models.Resource(
+ id=demo_uuid("resource", "perlmutter_compute_nodes"),
+ site_id=site1.id,
+ group="perlmutter",
+ name="compute nodes",
+ description="the perlmutter computer compute nodes",
+ capability_ids=[
+ self.capabilities["cpu"].id,
+ self.capabilities["gpu"].id,
+ ],
+ current_status=status_models.Status.degraded,
+ last_modified=day_ago,
+ resource_type=status_models.ResourceType.compute,
+ )
+
+ hpss = status_models.Resource(
+ id=demo_uuid("resource", "hpss"),
+ site_id=site1.id,
+ group="hpss",
+ name="hpss",
+ description="hpss tape storage",
+ capability_ids=[self.capabilities["hpss"].id],
+ current_status=status_models.Status.up,
+ last_modified=day_ago,
+ resource_type=status_models.ResourceType.storage,
+ )
+
+ cfs = status_models.Resource(
+ id=demo_uuid("resource", "cfs"),
+ site_id=site1.id,
+ group="cfs",
+ name="cfs",
+ description="cfs storage",
+ capability_ids=[self.capabilities["gpfs"].id],
+ current_status=status_models.Status.up,
+ last_modified=day_ago,
+ resource_type=status_models.ResourceType.storage,
+ )
+
+ login = status_models.Resource(
+ id=demo_uuid("resource", "login_nodes"),
+ site_id=site2.id,
+ group="perlmutter",
+ name="login nodes",
+ description="the perlmutter computer login nodes",
+ capability_ids=[],
+ current_status=status_models.Status.degraded,
+ last_modified=day_ago,
+ resource_type=status_models.ResourceType.system,
+ )
+
+ iris = status_models.Resource(
+ id=demo_uuid("resource", "iris"),
+ site_id=site2.id,
+ group="services",
+ name="Iris",
+ description="Iris webapp",
+ capability_ids=[],
+ current_status=status_models.Status.down,
+ last_modified=day_ago,
+ resource_type=status_models.ResourceType.website,
+ )
+ sfapi = status_models.Resource(
+ id=demo_uuid("resource", "sfapi"),
+ site_id=site2.id,
+ group="services",
+ name="sfapi",
+ description="the Superfacility API",
+ capability_ids=[],
+ current_status=status_models.Status.up,
+ last_modified=day_ago,
+ resource_type=status_models.ResourceType.service,
+ )
+
+ self.resources = [pm, hpss, cfs, login, iris, sfapi]
+
+ # Populate site resource_ids based on which resources are at each site
+ site1.resource_ids = [r.id for r in self.resources if r.site_id == site1.id]
+ site2.resource_ids = [r.id for r in self.resources if r.site_id == site2.id]
self.projects = [
account_models.Project(
- id=str(uuid.uuid4()),
+ id=demo_uuid("project", "staff_research"),
name="Staff research project",
description="Compute and storage allocation for staff research use",
- user_ids=[ "gtorok" ],
+ user_ids=["gtorok"],
+ last_modified=day_ago,
),
account_models.Project(
- id=str(uuid.uuid4()),
+ id=demo_uuid("project", "test_project"),
name="Test project",
description="Compute and storage allocation for testing use",
- user_ids=[ "gtorok" ],
+ user_ids=["gtorok"],
+ last_modified=day_ago,
),
]
for p in self.projects:
for c in self.capabilities.values():
pa = account_models.ProjectAllocation(
- id=str(uuid.uuid4()),
+ id=demo_uuid("project_allocation", f"{p.id}_{c.id}"),
project_id=p.id,
capability_id=c.id,
entries=[
@@ -109,27 +277,20 @@ def _init_state(self):
unit=cu,
)
for cu in c.units
- ]
+ ],
)
self.project_allocations.append(pa)
self.user_allocations.append(
account_models.UserAllocation(
- id=str(uuid.uuid4()),
+ id=demo_uuid("user_allocation", f"{pa.id}_gtorok"),
project_id=pa.project_id,
project_allocation_id=pa.id,
user_id="gtorok",
- entries=[
- account_models.AllocationEntry(
- allocation=a.allocation/10,
- usage=a.usage/10,
- unit=a.unit
- )
- for a in pa.entries
- ]
+ entries=[account_models.AllocationEntry(allocation=a.allocation / 10, usage=a.usage / 10, unit=a.unit) for a in pa.entries],
)
)
- statuses = { r.name: status_models.Status.up for r in self.resources }
+ statuses = {r.name: status_models.Status.up for r in self.resources}
last_incidents = {}
d = datetime.datetime(2025, 3, 1, 10, 0, 0, tzinfo=datetime.timezone.utc)
@@ -140,7 +301,7 @@ def _init_state(self):
r = random.choice(self.resources)
status = statuses[r.name]
event = status_models.Event(
- id=str(uuid.uuid4()),
+ id=demo_uuid("event", f"{r.name}_{d.isoformat()}"),
name=f"{r.name} is {status.value}",
description=f"{r.name} is {status.value}",
occurred_at=d,
@@ -164,7 +325,7 @@ def _init_state(self):
statuses[r.name] = status_models.Status.down
dstr = d.strftime("%Y-%m-%d %H:%M:%S.%f%z")
incident = status_models.Incident(
- id=str(uuid.uuid4()),
+ id=demo_uuid("incident", f"{r.name}_{dstr}"),
name=f"{r.name} incident at {dstr}",
description=f"{r.name} incident at {dstr}",
status=status_models.Status.down,
@@ -174,179 +335,228 @@ def _init_state(self):
end=d,
type=random.choice(list(status_models.IncidentType)),
resolution=random.choice(list(status_models.Resolution)),
- last_modified=d
+ last_modified=d,
)
self.incidents.append(incident)
last_incidents[r.name] = incident
-
d += datetime.timedelta(minutes=int(random.random() * 15 + 1))
+ # ----------------------------
+ # Facility API
+ # ----------------------------
- async def get_resources(
- self : "DemoAdapter",
- offset : int,
- limit : int,
- name : str | None = None,
- description : str | None = None,
- group : str | None = None,
- modified_since : datetime.datetime | None = None,
- resource_type : status_models.ResourceType | None = None,
- ) -> list[status_models.Resource]:
- return status_models.Resource.find(self.resources, name, description, group, modified_since, resource_type)[offset:offset + limit]
+ async def get_facility(self: "DemoAdapter", modified_since: str | None = None) -> facility_models.Facility:
+ return self.facility
+ async def list_sites(
+ self: "DemoAdapter", modified_since: str | None = None, name: str | None = None, offset: int | None = None, limit: int | None = None, short_name: str | None = None
+ ) -> list[facility_models.Site]:
+ sites = self.sites
- async def get_resource(
- self : "DemoAdapter",
- id : str
- ) -> status_models.Resource:
- return status_models.Resource.find_by_id(self.resources, id)
+ if name:
+ sites = [s for s in sites if name.lower() in s.name.lower()] # pylint: disable=no-member
+ if short_name:
+ sites = [s for s in sites if s.short_name == short_name]
+
+ if modified_since:
+ ms = datetime.datetime.fromisoformat(str(modified_since))
+ sites = [s for s in sites if s.last_modified > ms]
+
+ o = offset or 0
+ l = limit or len(sites)
+ return sites[o : o + l]
+
+ async def get_site(self: "DemoAdapter", site_id: str, modified_since: str | None = None) -> facility_models.Site:
+ site = next((s for s in self.sites if s.id == site_id), None)
+ if not site:
+ raise HTTPException(status_code=404, detail="Site not found")
+
+ if modified_since:
+ ms = datetime.datetime.fromisoformat(str(modified_since))
+ if site.last_modified <= ms:
+ raise HTTPException(status_code=304, headers={"Last-Modified": site.last_modified.isoformat()})
+
+ return site
+
+ # ----------------------------
+ # Status API
+ # ----------------------------
+
+ async def get_resources(
+ self: "DemoAdapter",
+ offset: int,
+ limit: int,
+ name: str | None = None,
+ description: str | None = None,
+ group: str | None = None,
+ modified_since: datetime.datetime | None = None,
+ resource_type: status_models.ResourceType | None = None,
+ current_status: status_models.Status | None = None,
+ capability: Capability | None = None,
+ site_id: str | None = None,
+ ) -> list[status_models.Resource]:
+ resources = status_models.Resource.find(
+ self.resources,
+ name=name,
+ description=description,
+ group=group,
+ modified_since=modified_since,
+ resource_type=resource_type,
+ current_status=current_status,
+ capability=capability,
+ site_id=site_id,
+ )
+ return paginate_list(resources, offset, limit)
+
+ async def get_resource(self: "DemoAdapter", id_: str) -> status_models.Resource:
+ return status_models.Resource.find_by_id(self.resources, id_)
async def get_events(
- self : "DemoAdapter",
- incident_id : str,
- offset : int,
- limit : int,
- resource_id : str | None = None,
- name : str | None = None,
- description : str | None = None,
- status : status_models.Status | None = None,
- from_ : datetime.datetime | None = None,
- to : datetime.datetime | None = None,
- time_ : datetime.datetime | None = None,
- modified_since : datetime.datetime | None = None,
- ) -> list[status_models.Event]:
- return status_models.Event.find([e for e in self.events if e.incident_id == incident_id], resource_id, name, description, status, from_, to, time_, modified_since)[offset:offset + limit]
-
-
- async def get_event(
- self : "DemoAdapter",
- incident_id : str,
- id : str
- ) -> status_models.Event:
- return status_models.Event.find_by_id(self.events, id)
+ self: "DemoAdapter",
+ offset: int,
+ limit: int,
+ incident_id: str | None = None,
+ resource_id: str | None = None,
+ name: str | None = None,
+ description: str | None = None,
+ status: status_models.Status | None = None,
+ from_: datetime.datetime | None = None,
+ to: datetime.datetime | None = None,
+ time_: datetime.datetime | None = None,
+ modified_since: datetime.datetime | None = None,
+ ) -> list[status_models.Event]:
+ events = status_models.Event.find(
+ self.events,
+ incident_id=incident_id,
+ resource_id=resource_id,
+ name=name,
+ description=description,
+ status=status,
+ from_=from_,
+ to=to,
+ time_=time_,
+ modified_since=modified_since,
+ )
+ return paginate_list(events, offset, limit)
+ async def get_event(self: "DemoAdapter", id_: str) -> status_models.Event:
+ return status_models.Event.find_by_id(self.events, id_)
async def get_incidents(
- self : "DemoAdapter",
- offset : int,
- limit : int,
- name : str | None = None,
- description : str | None = None,
- status : status_models.Status | None = None,
- type : status_models.IncidentType | None = None,
- from_ : datetime.datetime | None = None,
- to : datetime.datetime | None = None,
- time_ : datetime.datetime | None = None,
- modified_since : datetime.datetime | None = None,
- resource_id : str | None = None,
- ) -> list[status_models.Incident]:
- return status_models.Incident.find(self.incidents, name, description, status, type, from_, to, time_, modified_since, resource_id)[offset:offset + limit]
-
-
- async def get_incident(
- self : "DemoAdapter",
- id : str
- ) -> status_models.Incident:
- return status_models.Incident.find_by_id(self.incidents, id)
-
-
- async def get_capabilities(
- self : "DemoAdapter",
- ) -> list[account_models.Capability]:
- return self.capabilities.values()
+ self: "DemoAdapter",
+ offset: int,
+ limit: int,
+ name: str | None = None,
+ description: str | None = None,
+ status: status_models.Status | None = None,
+ type_: status_models.IncidentType | None = None,
+ from_: datetime.datetime | None = None,
+ to: datetime.datetime | None = None,
+ time_: datetime.datetime | None = None,
+ modified_since: datetime.datetime | None = None,
+ resource_id: str | None = None,
+ resolution: status_models.Resolution | None = None,
+ ) -> list[status_models.Incident]:
+ incidents = status_models.Incident.find(
+ self.incidents,
+ name=name,
+ description=description,
+ status=status,
+ type_=type_,
+ from_=from_,
+ to=to,
+ time_=time_,
+ modified_since=modified_since,
+ resource_id=resource_id,
+ resolution=resolution,
+ )
+ return paginate_list(incidents, offset, limit)
+
+ async def get_incident(self: "DemoAdapter", id_: str) -> status_models.Incident:
+ return status_models.Incident.find_by_id(self.incidents, id_)
+ async def get_capabilities(self: "DemoAdapter", name: str | None = None, modified_since: str | None = None, offset: int = 0, limit: int = 1000) -> list[Capability]:
+ return self.capabilities.values()
async def get_current_user(
- self : "DemoAdapter",
+ self: "DemoAdapter",
+ api_key: str,
+ client_ip: str,
+ ) -> str:
+ """
+ In a real deployment, this would decode the api_key jwt and return the current user's id.
+ This method is not async.
+ """
+ if api_key != self.user.api_key:
+ raise HTTPException(status_code=401, detail="Invalid API key")
+ return "gtorok"
+
+ async def get_current_user_globus(
+ self: "DemoAdapter",
api_key: str,
client_ip: str,
+ globus_introspect: dict | None,
) -> str:
"""
- In a real deployment, this would decode the api_key jwt and return the current user's id.
- This method is not async.
+ Decode the api_key and return the authenticated user's id from information returned by introspecting a globus token.
+ This method is not called directly, rather authorized endpoints "depend" on it.
+ (https://fastapi.tiangolo.com/tutorial/dependencies/)
"""
return "gtorok"
-
async def get_user(
- self : "DemoAdapter",
- user_id: str,
- api_key: str,
- client_ip: str|None,
- ) -> account_models.User:
+ self: "DemoAdapter",
+ user_id: str,
+ api_key: str,
+ client_ip: str | None,
+ globus_introspect: dict | None,
+ ) -> User:
if user_id != self.user.id:
- raise HTTPException(status_code=401, detail="User not found")
- if api_key != self.user.api_key:
- raise HTTPException(status_code=403, detail="Invalid API key")
+ raise HTTPException(status_code=403, detail="User not found")
+ if api_key.startswith("Bearer "):
+ api_key = api_key[len("Bearer ") :]
return self.user
-
- async def get_projects(
- self : "DemoAdapter",
- user: account_models.User
- ) -> list[account_models.Project]:
+ async def get_projects(self: "DemoAdapter", user: User) -> list[account_models.Project]:
return self.projects
-
async def get_project_allocations(
- self : "DemoAdapter",
+ self: "DemoAdapter",
project: account_models.Project,
- user: account_models.User
- ) -> list[account_models.ProjectAllocation]:
+ user: User,
+ ) -> list[account_models.ProjectAllocation]:
return [pa for pa in self.project_allocations if pa.project_id == project.id]
-
async def get_user_allocations(
- self : "DemoAdapter",
- user: account_models.User,
+ self: "DemoAdapter",
+ user: User,
project_allocation: account_models.ProjectAllocation,
- ) -> list[account_models.UserAllocation]:
+ ) -> list[account_models.UserAllocation]:
return [ua for ua in self.user_allocations if ua.project_allocation_id == project_allocation.id]
-
async def submit_job(
self: "DemoAdapter",
resource: status_models.Resource,
- user: account_models.User,
+ user: User,
job_spec: compute_models.JobSpec,
) -> compute_models.Job:
return compute_models.Job(
id="job_123",
status=compute_models.JobStatus(
state=compute_models.JobState.NEW,
- time=time.time(),
- message="job submitted",
- exit_code=None,
- meta_data={ "account": "account1" },
- )
- )
-
-
- async def submit_job_script(
- self: "DemoAdapter",
- resource: status_models.Resource,
- user: account_models.User,
- job_script_path: str,
- args: list[str] = [],
- ) -> compute_models.Job:
- return compute_models.Job(
- id="job_123",
- status=compute_models.JobStatus(
- state=compute_models.JobState.NEW,
- time=time.time(),
+ time=utc_timestamp(),
message="job submitted",
- exit_code=None,
- meta_data={ "account": "account1" },
- )
+ exit_code=0,
+ meta_data={"account": "account1"},
+ ),
)
-
async def update_job(
self: "DemoAdapter",
resource: status_models.Resource,
- user: account_models.User,
+ user: User,
job_spec: compute_models.JobSpec,
job_id: str,
) -> compute_models.Job:
@@ -354,18 +564,17 @@ async def update_job(
id=job_id,
status=compute_models.JobStatus(
state=compute_models.JobState.ACTIVE,
- time=time.time(),
+ time=utc_timestamp(),
message="job updated",
- exit_code=None,
- meta_data={ "account": "account1" },
- )
+ exit_code=0,
+ meta_data={"account": "account1"},
+ ),
)
-
async def get_job(
self: "DemoAdapter",
resource: status_models.Resource,
- user: account_models.User,
+ user: User,
job_id: str,
historical: bool = False,
include_spec: bool = False,
@@ -374,47 +583,48 @@ async def get_job(
id=job_id,
status=compute_models.JobStatus(
state=compute_models.JobState.COMPLETED,
- time=time.time(),
+ time=utc_timestamp(),
message="job completed successfully",
exit_code=0,
- meta_data={ "account": "account1" },
- )
+ meta_data={"account": "account1"},
+ ),
)
-
async def get_jobs(
self: "DemoAdapter",
resource: status_models.Resource,
- user: account_models.User,
- offset : int,
- limit : int,
+ user: User,
+ offset: int,
+ limit: int,
filters: dict[str, object] | None = None,
historical: bool = False,
include_spec: bool = False,
) -> list[compute_models.Job]:
- return [compute_models.Job(
- id=f"job_{i}",
- status=compute_models.JobStatus(
- state=random.choice([s for s in compute_models.JobState]),
- time=time.time() - (random.random() * 100),
- message="",
- exit_code=random.choice([0, 0, 0, 0, 0, 1, 1, 128, 127]),
- meta_data={ "account": "account1" },
+ return [
+ compute_models.Job(
+ id=f"job_{i}",
+ status=compute_models.JobStatus(
+ state=random.choice([s for s in compute_models.JobState]),
+ time=utc_timestamp() - int(random.random() * 100),
+ message="",
+ exit_code=random.choice([0, 0, 0, 0, 0, 1, 1, 128, 127]),
+ meta_data={"account": "account1"},
+ ),
)
- ) for i in range(random.randint(3, 10))]
-
+ for i in range(random.randint(3, 10))
+ ]
async def cancel_job(
self: "DemoAdapter",
resource: status_models.Resource,
- user: account_models.User,
+ user: User,
job_id: str,
) -> bool:
# call slurm/etc. to cancel job
return True
-
def validate_path(self, path: str, allow_symlinks: bool = True) -> str:
+ """Validate that the given path is within the sandbox base directory and optionally check for symlinks."""
basedir = PathSandbox.get_base_temp_dir()
real_path = os.path.realpath(os.path.join(basedir, path))
@@ -430,6 +640,26 @@ def validate_path(self, path: str, allow_symlinks: bool = True) -> str:
return real_path
+# ----------------------------------------------
+# Filesystem API
+# ----------------------------------------------
+ def _run(self, args, *, shell: bool = False, timeout: int | None = 3600, text: bool = True) -> subprocess.CompletedProcess:
+ """
+ Run a subprocess command and catch exceptions.
+ Raises CommandError on failure with captured diagnostics.
+ """
+ try:
+ return subprocess.run(args, shell=shell, capture_output=True, text=text, check=True, timeout=timeout)
+ except subprocess.TimeoutExpired as exc:
+ logger.warning(f"Command timed out: {args} (after {timeout} seconds)")
+ raise CommandError(cmd=args, returncode=None, stdout=exc.stdout, stderr=exc.stderr) from exc
+ except subprocess.CalledProcessError as exc:
+ logger.warning(f"Command failed: {args} (rc={exc.returncode})\nstdout: {exc.stdout}\nstderr: {exc.stderr}")
+ raise CommandError(cmd=args, returncode=exc.returncode, stdout=exc.stdout, stderr=exc.stderr) from exc
+ except OSError as exc:
+ logger.warning(f"OS error running command: {args}\nError: {exc}")
+ raise CommandError(cmd=args, returncode=None, stdout=None, stderr=str(exc)) from exc
+
def _file(self, path: str) -> filesystem_models.File:
# Get file stats (follows symlinks by default)
@@ -459,52 +689,45 @@ def _file(self, path: str) -> filesystem_models.File:
permissions = stat.filemode(file_stat.st_mode)
# Get last modified time
- last_modified = datetime.datetime.fromtimestamp(file_stat.st_mtime).strftime('%Y-%m-%d %H:%M:%S')
+ last_modified = datetime.datetime.fromtimestamp(file_stat.st_mtime).strftime("%Y-%m-%d %H:%M:%S")
# Get size
size = str(file_stat.st_size)
-
- return filesystem_models.File(
+ data = dict(
name=os.path.basename(rp),
type=file_type,
- link_target=link_target,
user=user,
group=group,
permissions=permissions,
last_modified=last_modified,
- size=size
+ size=size,
)
- async def chmod(
- self : "DemoAdapter",
- resource: status_models.Resource,
- user: account_models.User,
- request_model: filesystem_models.PutFileChmodRequest
- ) -> filesystem_models.PutFileChmodResponse:
+ if link_target is not None:
+ data["link_target"] = link_target
+
+ return filesystem_models.File(**data)
+
+
+ async def chmod(self: "DemoAdapter", resource: status_models.Resource, user: User, request_model: filesystem_models.PutFileChmodRequest) -> filesystem_models.PutFileChmodResponse:
rp = self.validate_path(request_model.path)
os.chmod(rp, int(request_model.mode, 8))
- return filesystem_models.PutFileChmodResponse(
- output=self._file(rp)
- )
-
+ return filesystem_models.PutFileChmodResponse(output=self._file(rp))
async def chown(
- self : "DemoAdapter",
+ self: "DemoAdapter",
resource: status_models.Resource,
- user: account_models.User,
- request_model: filesystem_models.PutFileChownRequest
+ user: User,
+ request_model: filesystem_models.PutFileChownRequest,
) -> filesystem_models.PutFileChownResponse:
rp = self.validate_path(request_model.path)
os.chown(rp, request_model.owner, request_model.group)
- return filesystem_models.PutFileChmodResponse(
- output=self._file(rp)
- )
-
+ return filesystem_models.PutFileChownResponse(output=self._file(rp))
async def ls(
- self : "DemoAdapter",
+ self: "DemoAdapter",
resource: status_models.Resource,
- user: account_models.User,
+ user: User,
path: str,
show_hidden: bool,
numeric_uid: bool,
@@ -513,93 +736,102 @@ async def ls(
) -> filesystem_models.GetDirectoryLsResponse:
rp = self.validate_path(path)
files = glob.glob(rp, recursive=recursive)
- return filesystem_models.GetDirectoryLsResponse(
- output=[self._file(f) for f in files]
- )
-
+ return filesystem_models.GetDirectoryLsResponse(output=[self._file(f) for f in files])
def _headtail(
- self : "DemoAdapter",
+ self: "DemoAdapter",
cmd: str,
path: str,
file_bytes: int | None,
lines: int | None,
- ) -> Tuple[Any, int]:
+ skip_heading: bool = False,
+ skip_trailing: bool = False,
+ ) -> str:
args = [cmd]
- if file_bytes:
- args.append("-c")
- args.append(str(file_bytes))
- elif lines:
- args.append("-n")
- args.append(str(lines))
+
+ if cmd == "tail" and skip_heading:
+ if file_bytes is not None:
+ args.extend(["-c", f"+{file_bytes + 1}"])
+ elif lines is not None:
+ args.extend(["-n", f"+{lines + 1}"])
+ if cmd == "head" and skip_trailing:
+ if file_bytes is not None:
+ args.extend(["-c", f"-{file_bytes}"])
+ elif lines is not None:
+ args.extend(["-n", f"-{lines}"])
+ else:
+ if file_bytes is not None:
+ args.extend(["-c", str(file_bytes)])
+ elif lines is not None:
+ args.extend(["-n", str(lines)])
+
rp = self.validate_path(path)
args.append(rp)
- result = subprocess.run(
- args,
- capture_output=True,
- text=True
- )
- content = result.stdout
- return content, -len(content)
+ result = self._run(args)
+ return result.stdout
async def head(
- self : "DemoAdapter",
+ self: "DemoAdapter",
resource: status_models.Resource,
- user: account_models.User,
+ user: User,
path: str,
file_bytes: int | None,
lines: int | None,
- skip_trailing: bool,
- ) -> Tuple[Any, int]:
- return self._headtail("head", path, file_bytes, lines)
+ skip_trailing: bool = False,
+ ) -> filesystem_models.GetFileHeadResponse:
+ content = self._headtail("head", path, file_bytes, lines, skip_trailing=skip_trailing)
+
+ fc = filesystem_models.FileContent(
+ content=content,
+ content_type=(filesystem_models.ContentUnit.bytes
+ if file_bytes is not None
+ else filesystem_models.ContentUnit.lines),
+ start_position=0,
+ end_position=len(content))
+ return filesystem_models.GetFileHeadResponse(output=fc)
async def tail(
- self : "DemoAdapter",
+ self: "DemoAdapter",
resource: status_models.Resource,
- user: account_models.User,
+ user: User,
path: str,
file_bytes: int | None,
lines: int | None,
- skip_trailing: bool,
- ) -> Tuple[Any, int]:
- return self._headtail("tail", path, file_bytes, lines)
+ skip_heading: bool = False,
+ ) -> filesystem_models.GetFileTailResponse:
+ content = self._headtail("tail", path, file_bytes, lines, skip_heading=skip_heading)
- async def view(
- self : "DemoAdapter",
- resource: status_models.Resource,
- user: account_models.User,
- path: str,
- size: int,
- offset: int,
- ) -> filesystem_models.GetViewFileResponse:
+ fc = filesystem_models.FileContent(
+ content=content,
+ content_type=(filesystem_models.ContentUnit.bytes
+ if file_bytes is not None
+ else filesystem_models.ContentUnit.lines),
+ start_position=0,
+ end_position=len(content))
+
+ return filesystem_models.GetFileTailResponse(output=fc)
+
+
+
+ async def view(self: "DemoAdapter", resource: status_models.Resource, user: User, path: str, size: int, offset: int) -> filesystem_models.GetViewFileResponse:
rp = self.validate_path(path)
- result = subprocess.run(
- f"tail -c +{offset+1} {rp} | head -c {size}",
- shell=True,
- capture_output=True,
- text=True
- )
+ result = self._run(f"tail -c +{offset + 1} {rp} | head -c {size}", shell=True)
content = result.stdout
return filesystem_models.GetViewFileResponse(
- output=content,
+ output=filesystem_models.FileContent(
+ content=content,
+ content_type=filesystem_models.ContentUnit.bytes,
+ start_position=offset,
+ end_position=offset + len(content)
+ ),
)
-
- async def checksum(
- self : "DemoAdapter",
- resource: status_models.Resource,
- user: account_models.User,
- path: str,
- ) -> filesystem_models.GetFileChecksumResponse:
+ async def checksum(self: "DemoAdapter", resource: status_models.Resource, user: User, path: str) -> filesystem_models.GetFileChecksumResponse:
rp = self.validate_path(path)
- result = subprocess.run(
- ["sha256sum", rp],
- capture_output=True,
- text=True
- )
+ result = self._run(["sha256sum", rp])
checksum = result.stdout.split()[0]
return filesystem_models.GetFileChecksumResponse(
output=filesystem_models.FileChecksum(
@@ -607,38 +839,21 @@ async def checksum(
)
)
-
- async def file(
- self : "DemoAdapter",
- resource: status_models.Resource,
- user: account_models.User,
- path: str,
- ) -> filesystem_models.GetFileTypeResponse:
+ async def file(self: "DemoAdapter", resource: status_models.Resource, user: User, path: str) -> filesystem_models.GetFileTypeResponse:
rp = self.validate_path(path)
- result = subprocess.run(
- ["file", "-b", rp],
- capture_output=True,
- text=True
- )
+ result = self._run(["file", "-b", rp])
return filesystem_models.GetFileTypeResponse(
output=result.stdout.strip(),
)
-
- async def stat(
- self : "DemoAdapter",
- resource: status_models.Resource,
- user: account_models.User,
- path: str,
- dereference: bool,
- ) -> filesystem_models.GetFileStatResponse:
+ async def stat(self: "DemoAdapter", resource: status_models.Resource, user: User, path: str, dereference: bool) -> filesystem_models.GetFileStatResponse:
rp = self.validate_path(path)
if dereference:
stat_info = os.stat(rp)
else:
stat_info = os.lstat(rp)
return filesystem_models.GetFileStatResponse(
- output=filesystem_models.FileStat(
+ output=filesystem_models.FileStat(
mode=stat_info.st_mode,
ino=stat_info.st_ino,
dev=stat_info.st_dev,
@@ -648,77 +863,51 @@ async def stat(
size=stat_info.st_size,
atime=int(stat_info.st_atime),
ctime=int(stat_info.st_ctime),
- mtime=int(stat_info.st_mtime)
+ mtime=int(stat_info.st_mtime),
)
)
-
async def rm(
- self : "DemoAdapter",
+ self: "DemoAdapter",
resource: status_models.Resource,
- user: account_models.User,
+ user: User,
path: str,
- ):
+ ) -> filesystem_models.RemoveResponse:
rp = self.validate_path(path)
if rp == PathSandbox.get_base_temp_dir():
raise HTTPException(status_code=400, detail="Cannot delete sandbox")
- subprocess.run(["rm", "-rf", rp], check=True)
- return None
-
+ self._run(["rm", "-rf", rp])
+ return filesystem_models.RemoveResponse(output=f"Removed {rp}")
- async def mkdir(
- self : "DemoAdapter",
- resource: status_models.Resource,
- user: account_models.User,
- request_model: filesystem_models.PostMakeDirRequest,
- ) -> filesystem_models.PostMkdirResponse:
+ async def mkdir(self: "DemoAdapter", resource: status_models.Resource, user: User, request_model: filesystem_models.PostMakeDirRequest) -> filesystem_models.PostMkdirResponse:
rp = self.validate_path(request_model.path)
args = ["mkdir"]
if request_model.parent:
args.append("-p")
args.append(rp)
- subprocess.run(args, check=True)
- return filesystem_models.PostMkdirResponse(
- output=self._file(rp)
- )
-
+ self._run(args)
+ return filesystem_models.PostMkdirResponse(output=self._file(rp))
async def symlink(
- self : "DemoAdapter",
- resource: status_models.Resource,
- user: account_models.User,
- request_model: filesystem_models.PostFileSymlinkRequest,
+ self: "DemoAdapter", resource: status_models.Resource, user: User, request_model: filesystem_models.PostFileSymlinkRequest
) -> filesystem_models.PostFileSymlinkResponse:
rp_src = self.validate_path(request_model.path)
rp_dst = self.validate_path(request_model.link_path)
- subprocess.run(["ln", "-s", rp_src, rp_dst], check=True)
- return filesystem_models.PostFileSymlinkResponse(
- output=self._file(rp_dst)
- )
+ self._run(["ln", "-s", rp_src, rp_dst])
+ return filesystem_models.PostFileSymlinkResponse(output=self._file(rp_dst))
-
- async def download(
- self : "DemoAdapter",
- resource: status_models.Resource,
- user: account_models.User,
- path: str,
- ) -> Any:
+ async def download(self: "DemoAdapter", resource: status_models.Resource, user: User, path: str) -> filesystem_models.GetFileDownloadResponse:
rp = self.validate_path(path)
raw_content = pathlib.Path(rp).read_bytes()
if len(raw_content) > filesystem_adapter.OPS_SIZE_LIMIT:
raise Exception("File to download is too large.")
- return base64.b64encode(raw_content).decode('utf-8')
-
+ return filesystem_models.GetFileDownloadResponse(
+ output=base64.b64encode(raw_content).decode("utf-8"),
+ )
- async def upload(
- self : "DemoAdapter",
- resource: status_models.Resource,
- user: account_models.User,
- path: str,
- content: str,
- ) -> None:
+ async def upload(self: "DemoAdapter", resource: status_models.Resource, user: User, path: str, content: str) -> filesystem_models.PutFileUploadResponse:
rp = self.validate_path(path)
if isinstance(content, bytes):
pathlib.Path(rp).write_bytes(content)
@@ -726,18 +915,15 @@ async def upload(
pathlib.Path(rp).write_bytes(base64.b64decode(content))
else:
raise Exception(f"Don't know how to handle variable of type: {type(content)}")
-
+ return filesystem_models.PutFileUploadResponse(output=f"Uploaded to {rp}")
async def compress(
- self : "DemoAdapter",
- resource: status_models.Resource,
- user: account_models.User,
- request_model: filesystem_models.PostCompressRequest,
+ self: "DemoAdapter", resource: status_models.Resource, user: User, request_model: filesystem_models.PostCompressRequest
) -> filesystem_models.PostCompressResponse:
src_rp = self.validate_path(request_model.path)
dst_rp = self.validate_path(request_model.target_path)
- args = [ "tar" ]
+ args = ["tar"]
if request_model.compression == filesystem_models.CompressionType.gzip:
args.append("-czf")
elif request_model.compression == filesystem_models.CompressionType.bzip2:
@@ -756,21 +942,20 @@ async def compress(
args.append(p.relative_to(PathSandbox.get_base_temp_dir()))
subprocess.run(args, check=True)
- return filesystem_models.PostCompressResponse(
- output=self._file(dst_rp)
- )
+ return filesystem_models.PostCompressResponse(output=self._file(dst_rp))
-
- async def extract(
- self : "DemoAdapter",
- resource: status_models.Resource,
- user: account_models.User,
- request_model: filesystem_models.PostExtractRequest,
- ) -> filesystem_models.PostExtractResponse:
+ async def extract(self: "DemoAdapter", resource: status_models.Resource, user: User, request_model: filesystem_models.PostExtractRequest) -> filesystem_models.PostExtractResponse:
src_rp = self.validate_path(request_model.path)
dst_rp = self.validate_path(request_model.target_path)
- args = [ "tar" ]
+ if os.path.exists(dst_rp):
+ if os.path.isdir(dst_rp):
+ raise Exception(f"Target path already exists: {request_model.target_path}")
+ else:
+ raise Exception(f"Target path already exists and is not a directory: {request_model.target_path}")
+ os.makedirs(dst_rp)
+
+ args = ["tar"]
if request_model.compression == filesystem_models.CompressionType.gzip:
args.append("-xzf")
elif request_model.compression == filesystem_models.CompressionType.bzip2:
@@ -784,31 +969,15 @@ async def extract(
args.append(dst_rp)
subprocess.run(args, check=True)
- return filesystem_models.PostExtractResponse(
- output=self._file(dst_rp)
- )
-
+ return filesystem_models.PostExtractResponse(output=self._file(dst_rp))
- async def mv(
- self : "DemoAdapter",
- resource: status_models.Resource,
- user: account_models.User,
- request_model: filesystem_models.PostMoveRequest,
- ) -> filesystem_models.PostMoveResponse:
+ async def mv(self: "DemoAdapter", resource: status_models.Resource, user: User, request_model: filesystem_models.PostMoveRequest) -> filesystem_models.PostMoveResponse:
src_rp = self.validate_path(request_model.path)
dst_rp = self.validate_path(request_model.target_path)
subprocess.run(["mv", src_rp, dst_rp], check=True)
- return filesystem_models.PostMoveResponse(
- output=self._file(dst_rp)
- )
-
+ return filesystem_models.PostMoveResponse(output=self._file(dst_rp))
- async def cp(
- self : "DemoAdapter",
- resource: status_models.Resource,
- user: account_models.User,
- request_model: filesystem_models.PostCopyRequest,
- ) -> filesystem_models.PostCopyResponse:
+ async def cp(self: "DemoAdapter", resource: status_models.Resource, user: User, request_model: filesystem_models.PostCopyRequest) -> filesystem_models.PostCopyResponse:
src_rp = self.validate_path(request_model.path)
dst_rp = self.validate_path(request_model.target_path)
args = ["cp"]
@@ -817,54 +986,48 @@ async def cp(
args.append(src_rp)
args.append(dst_rp)
subprocess.run(args, check=True)
- return filesystem_models.PostCopyResponse(
- output=self._file(dst_rp)
- )
-
+ return filesystem_models.PostCopyResponse(output=self._file(dst_rp))
- async def get_task(
- self : "DemoAdapter",
- user: account_models.User,
- task_id: str,
- ) -> task_models.Task|None:
- await DemoTaskQueue._process_tasks(self)
+ async def get_task(self: "DemoAdapter", user: User, task_id: str) -> task_models.Task | None:
+ await DemoTaskQueue.process_tasks(self)
return next((t for t in DemoTaskQueue.tasks if t.user.name == user.name and t.id == task_id), None)
-
- async def get_tasks(
- self : "DemoAdapter",
- user: account_models.User,
- ) -> list[task_models.Task]:
- await DemoTaskQueue._process_tasks(self)
+ async def get_tasks(self: "DemoAdapter", user: User) -> list[task_models.Task]:
+ await DemoTaskQueue.process_tasks(self)
return [t for t in DemoTaskQueue.tasks if t.user.name == user.name]
+ async def put_task(self: "DemoAdapter", user: User, resource: status_models.Resource, task: str) -> task_models.TaskSubmitResponse:
+ await DemoTaskQueue.process_tasks(self)
+ return DemoTaskQueue.create_task(user, resource, task)
- async def put_task(
- self: "DemoAdapter",
- user: account_models.User,
- resource: status_models.Resource,
- body: str
- ) -> str:
- await DemoTaskQueue._process_tasks(self)
- return DemoTaskQueue._create_task(user, resource, body)
+ async def delete_task(self: "DemoAdapter", user: User, task_id: str) -> None:
+ await DemoTaskQueue.process_tasks(self)
+ for t in DemoTaskQueue.tasks:
+ if t.user.name == user.name and t.id == task_id:
+ t.status = task_models.TaskStatus.canceled
+ t.result = None
+ break
class DemoTask(BaseModel):
+ """A simple in-memory task queue for demonstration purposes."""
id: str
- body: str
+ task: str
resource: status_models.Resource
- user: account_models.User
+ user: User
start: float
- status: task_models.TaskStatus=task_models.TaskStatus.pending
- result: str|None=None
+ status: task_models.TaskStatus = task_models.TaskStatus.pending
+ result: dict | None = None
class DemoTaskQueue:
+ """A simple in-memory task queue for demonstration purposes."""
tasks = []
@staticmethod
- async def _process_tasks(da: DemoAdapter):
- now = time.time()
+ async def process_tasks(da: DemoAdapter):
+ """Process tasks in the queue, simulating task execution and completion."""
+ now = utc_timestamp()
_tasks = []
for t in DemoTaskQueue.tasks:
if now - t.start > 5 * 60 and t.status in [task_models.TaskStatus.completed, task_models.TaskStatus.canceled, task_models.TaskStatus.failed]:
@@ -874,16 +1037,22 @@ async def _process_tasks(da: DemoAdapter):
t.status = task_models.TaskStatus.active
t.start = now
elif t.status == task_models.TaskStatus.active and now - t.start > DEMO_QUEUE_UPDATE_SECS:
- cmd = task_models.TaskCommand.model_validate_json(t.body)
+ cmd = task_models.TaskCommand.model_validate_json(t.task)
(result, status) = await DemoAdapter.on_task(t.resource, t.user, cmd)
- t.result = result
+ if isinstance(result, BaseModel):
+ t.result = result.model_dump()
+ elif isinstance(result, dict):
+ t.result = result
+ else:
+ t.result = {"output": result}
t.status = status
_tasks.append(t)
DemoTaskQueue.tasks = _tasks
-
@staticmethod
- def _create_task(user: account_models.User, resource: status_models.Resource, command: task_models.TaskCommand) -> str:
+ def create_task(user: User, resource: status_models.Resource, command: task_models.TaskCommand) -> task_models.TaskSubmitResponse:
+ """Create a new task in the queue."""
task_id = f"task_{len(DemoTaskQueue.tasks)}"
- DemoTaskQueue.tasks.append(DemoTask(id=task_id, body=command.model_dump_json(), user=user, resource=resource, start=time.time()))
- return task_id
+ DemoTaskQueue.tasks.append(DemoTask(id=task_id, task=command.model_dump_json(), user=user, resource=resource, start=utc_timestamp()))
+ logger.info(f"Created task: {task_id}")
+ return task_models.TaskSubmitResponse(task_id=task_id)
diff --git a/app/main.py b/app/main.py
index 0b902dc8..2019bece 100644
--- a/app/main.py
+++ b/app/main.py
@@ -1,9 +1,19 @@
#!/usr/bin/env python3
"""Main API application"""
+
import logging
-from fastapi import FastAPI
+from fastapi import FastAPI, Request
+from opentelemetry import trace
+from starlette.middleware.base import BaseHTTPMiddleware
+from opentelemetry.sdk.resources import Resource
+from opentelemetry.sdk.trace import TracerProvider
+from opentelemetry.sdk.trace.export import ConsoleSpanExporter, BatchSpanProcessor, SimpleSpanProcessor
+from opentelemetry.sdk.trace.sampling import TraceIdRatioBased, ParentBased
+from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
+from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor
from app.routers.error_handlers import install_error_handlers
+from app.routers.facility import facility
from app.routers.status import status
from app.routers.account import account
from app.routers.compute import compute
@@ -11,19 +21,61 @@
from app.routers.task import task
from . import config
+from .request_context import set_api_url_base, _api_url_base
+
+logging.basicConfig(
+ level=logging.INFO,
+ format="%(asctime)s %(levelname)s %(name)s: %(message)s"
+)
+
+# ------------------------------------------------------------------
+# OpenTelemetry Tracing Configuration
+# ------------------------------------------------------------------
+if config.OPENTELEMETRY_ENABLED:
+ resource = Resource.create({"service.name": "iri-facility-api", "service.version": config.API_VERSION, "service.endpoint": config.API_URL_ROOT})
+
+ samplerate = "1.0" if config.OPENTELEMETRY_DEBUG else config.OTEL_SAMPLE_RATE
+ provider = TracerProvider(resource=resource, sampler=ParentBased(TraceIdRatioBased(samplerate)))
+ trace.set_tracer_provider(provider)
+
+ if config.OTLP_ENDPOINT:
+ exporter = OTLPSpanExporter(endpoint=config.OTLP_ENDPOINT, insecure=True)
+ span_processor = BatchSpanProcessor(exporter)
+ else:
+ exporter = ConsoleSpanExporter()
+ span_processor = SimpleSpanProcessor(exporter)
+ provider.add_span_processor(span_processor)
+ tracer = trace.get_tracer(__name__)
+# ------------------------------------------------------------------
+
+APP = FastAPI(servers=[{"url": config.API_URL_ROOT}], **config.API_CONFIG)
+
+
+class _ExternalRequestContextMiddleware(BaseHTTPMiddleware):
+ async def dispatch(self, request: Request, call_next):
+ token = _api_url_base.set(None)
+ try:
+ set_api_url_base(request)
+ return await call_next(request)
+ finally:
+ _api_url_base.reset(token)
+
+APP.add_middleware(_ExternalRequestContextMiddleware)
-app = FastAPI(**config.API_CONFIG)
+if config.OPENTELEMETRY_ENABLED:
+ FastAPIInstrumentor.instrument_app(APP)
-install_error_handlers(app)
+install_error_handlers(APP)
api_prefix = f"{config.API_PREFIX}{config.API_URL}"
# Attach routers under the prefix
-app.include_router(status.router, prefix=api_prefix)
-app.include_router(account.router, prefix=api_prefix)
-app.include_router(compute.router, prefix=api_prefix)
-app.include_router(filesystem.router, prefix=api_prefix)
-app.include_router(task.router, prefix=api_prefix)
+APP.include_router(facility.router, prefix=api_prefix)
+APP.include_router(status.router, prefix=api_prefix)
+APP.include_router(account.router, prefix=api_prefix)
+APP.include_router(compute.router, prefix=api_prefix)
+APP.include_router(filesystem.router, prefix=api_prefix)
+APP.include_router(task.router, prefix=api_prefix)
logging.getLogger().info(f"API path: {api_prefix}")
diff --git a/app/request_context.py b/app/request_context.py
new file mode 100644
index 00000000..cb35184b
--- /dev/null
+++ b/app/request_context.py
@@ -0,0 +1,30 @@
+"""Per-request URL context derived from forwarding headers. (e.g. for Kong or other API gateways)"""
+from contextvars import ContextVar
+
+from fastapi import Request
+
+from . import config
+
+_api_url_base: ContextVar[str | None] = ContextVar("_api_url_base", default=None)
+
+
+def set_api_url_base(request: Request) -> None:
+ """Set the per-request API URL base from forwarding headers."""
+ host = (request.headers.get("x-forwarded-host") or
+ request.headers.get("host", "")).split(",")[0].strip()
+ proto = (request.headers.get("x-forwarded-proto") or
+ request.url.scheme).split(",")[0].strip()
+ prefix = (request.headers.get("x-forwarded-prefix")
+ or request.headers.get("x-script-name")
+ or "").rstrip("/")
+ api_url = config.API_URL.strip("/")
+ if host:
+ _api_url_base.set(f"{proto}://{host}{prefix}/{api_url}")
+
+
+def get_url_prefix() -> str:
+ """Return the per-request API URL base, or fall back to static config."""
+ value = _api_url_base.get()
+ if value:
+ return value
+ return f"{config.API_URL_ROOT}{config.API_PREFIX}{config.API_URL}"
diff --git a/app/routers/account/account.py b/app/routers/account/account.py
index 951fc9b7..35ba9cb8 100644
--- a/app/routers/account/account.py
+++ b/app/routers/account/account.py
@@ -1,8 +1,13 @@
-from fastapi import HTTPException, Request, Depends
-from . import models, facility_adapter
+from fastapi import Depends, HTTPException, Query, Request
+
+from ...types.http import forbidExtraQueryParams
+from ...types.models import Capability
+from ...types.scalars import StrictDateTime
+from ...types.user import User
from .. import iri_router
from ..error_handlers import DEFAULT_RESPONSES
-
+from ..iri_meta import iri_meta_dict
+from . import facility_adapter, models
router = iri_router.IriRouter(
facility_adapter.FacilityAdapter,
@@ -17,12 +22,18 @@
description="Get a list of capabilities at this facility.",
responses=DEFAULT_RESPONSES,
operation_id="getCapabilities",
-
+ response_model_exclude_none=True,
+ openapi_extra=iri_meta_dict("production", "required")
)
async def get_capabilities(
- request : Request,
- ) -> list[models.Capability]:
- return await router.adapter.get_capabilities()
+ request: Request,
+ name: str | None = Query(default=None, min_length=1),
+ modified_since: StrictDateTime = Query(default=None),
+ offset: int = Query(default=0, ge=0, le=1000),
+ limit: int = Query(default=100, ge=0, le=1000),
+ _forbid=Depends(forbidExtraQueryParams("name", "modified_since", "offset", "limit")),
+) -> list[Capability]:
+ return await router.adapter.get_capabilities(name=name, modified_since=modified_since, offset=offset, limit=limit)
@router.get(
@@ -31,12 +42,15 @@ async def get_capabilities(
description="Get a single capability at this facility.",
responses=DEFAULT_RESPONSES,
operation_id="getCapability",
+ openapi_extra=iri_meta_dict("production", "required")
)
async def get_capability(
- capability_id : str,
- request : Request,
- ) -> models.Capability:
- caps = await router.adapter.get_capabilities()
+ capability_id: str,
+ request: Request,
+ modified_since: StrictDateTime = Query(default=None),
+ _forbid=Depends(forbidExtraQueryParams("modified_since")),
+) -> Capability:
+ caps = await router.adapter.get_capabilities(name=None, modified_since=modified_since, offset=0, limit=100)
cc = next((c for c in caps if c.id == capability_id), None)
if not cc:
raise HTTPException(status_code=404, detail="Capability not found")
@@ -45,37 +59,35 @@ async def get_capability(
@router.get(
"/projects",
- dependencies=[Depends(router.current_user)],
summary="Get the projects of the current user",
description="Get a list of projects for the currently authenticated user at this facility.",
responses=DEFAULT_RESPONSES,
operation_id="getProjects",
+ openapi_extra=iri_meta_dict("production", "required")
)
async def get_projects(
- request : Request,
- ) -> list[models.Project]:
- user = await router.adapter.get_user(request.state.current_user_id, request.state.api_key, iri_router.get_client_ip(request))
- if not user:
- raise HTTPException(status_code=404, detail="User not found")
+ request: Request,
+ user: User = Depends(router.current_user),
+ _forbid=Depends(forbidExtraQueryParams()),
+) -> list[models.Project]:
return await router.adapter.get_projects(user)
@router.get(
"/projects/{project_id}",
- dependencies=[Depends(router.current_user)],
summary="Get a single project",
description="Get a single project at this facility.",
responses=DEFAULT_RESPONSES,
operation_id="getProject",
+ openapi_extra=iri_meta_dict("production", "required")
)
async def get_project(
- project_id : str,
- request : Request,
- ) -> models.Project:
- user = await router.adapter.get_user(request.state.current_user_id, request.state.api_key, iri_router.get_client_ip(request))
- if not user:
- raise HTTPException(status_code=404, detail="User not found")
- projects = await router.adapter.get_projects(user)
+ project_id: str,
+ request: Request,
+ user: User = Depends(router.current_user),
+ _forbid=Depends(forbidExtraQueryParams()),
+) -> models.Project:
+ projects = await router.adapter.get_projects(user=user)
pp = next((p for p in projects if p.id == project_id), None)
if not pp:
raise HTTPException(status_code=404, detail="Project not found")
@@ -84,45 +96,45 @@ async def get_project(
@router.get(
"/projects/{project_id}/project_allocations",
- dependencies=[Depends(router.current_user)],
summary="Get the allocations of the current user's projects",
description="Get a list of allocations for the currently authenticated user's projects at this facility.",
responses=DEFAULT_RESPONSES,
operation_id="getProjectAllocationsByProject",
+ openapi_extra=iri_meta_dict("production", "required")
)
async def get_project_allocations(
project_id: str,
- request : Request,
- ) -> list[models.ProjectAllocation]:
- user = await router.adapter.get_user(request.state.current_user_id, request.state.api_key, iri_router.get_client_ip(request))
- if not user:
- raise HTTPException(status_code=404, detail="User not found")
- projects = await router.adapter.get_projects(user)
+ request: Request,
+ user: User = Depends(router.current_user),
+ _forbid=Depends(forbidExtraQueryParams()),
+) -> list[models.ProjectAllocation]:
+ projects = await router.adapter.get_projects(user=user)
project = next((p for p in projects if p.id == project_id), None)
if not project:
raise HTTPException(status_code=404, detail="Project not found")
- return await router.adapter.get_project_allocations(project, user)
+ return await router.adapter.get_project_allocations(project=project, user=user)
@router.get(
"/projects/{project_id}/project_allocations/{project_allocation_id}",
- dependencies=[Depends(router.current_user)],
summary="Get a single project allocation",
description="Get a single project allocation at this facility for this user.",
responses=DEFAULT_RESPONSES,
operation_id="getProjectAllocationByProject",
+ openapi_extra=iri_meta_dict("production", "required")
)
async def get_project_allocation(
project_id: str,
- project_allocation_id : str,
- request : Request,
- ) -> models.ProjectAllocation:
- user = await router.adapter.get_user(request.state.current_user_id, request.state.api_key, iri_router.get_client_ip(request))
- if not user:
- raise HTTPException(status_code=404, detail="User not found")
- projects = await router.adapter.get_projects(user)
+ project_allocation_id: str,
+ request: Request,
+ user: User = Depends(router.current_user),
+ _forbid=Depends(forbidExtraQueryParams()),
+) -> models.ProjectAllocation:
+ projects = await router.adapter.get_projects(user=user)
project = next((p for p in projects if p.id == project_id), None)
- pas = await router.adapter.get_project_allocations(project, user)
+ if not project:
+ raise HTTPException(status_code=404, detail="Project not found")
+ pas = await router.adapter.get_project_allocations(project=project, user=user)
pa = next((pa for pa in pas if pa.id == project_allocation_id), None)
if not pa:
raise HTTPException(status_code=404, detail="Project allocation not found")
@@ -131,57 +143,55 @@ async def get_project_allocation(
@router.get(
"/projects/{project_id}/project_allocations/{project_allocation_id}/user_allocations",
- dependencies=[Depends(router.current_user)],
summary="Get the user allocations of the current user's projects",
description="Get a list of user allocations for the currently authenticated user's projects at this facility.",
responses=DEFAULT_RESPONSES,
operation_id="getUserAllocationsByProjectAllocation",
+ openapi_extra=iri_meta_dict("production", "required")
)
async def get_user_allocations(
project_id: str,
- project_allocation_id : str,
- request : Request,
- ) -> list[models.UserAllocation]:
- user = await router.adapter.get_user(request.state.current_user_id, request.state.api_key, iri_router.get_client_ip(request))
- if not user:
- raise HTTPException(status_code=404, detail="User not found")
- projects = await router.adapter.get_projects(user)
+ project_allocation_id: str,
+ request: Request,
+ _forbid=Depends(forbidExtraQueryParams()),
+ user: User = Depends(router.current_user),
+) -> list[models.UserAllocation]:
+ projects = await router.adapter.get_projects(user=user)
project = next((p for p in projects if p.id == project_id), None)
if not project:
raise HTTPException(status_code=404, detail="Project not found")
- pas = await router.adapter.get_project_allocations(project, user)
+ pas = await router.adapter.get_project_allocations(project=project, user=user)
pa = next((pa for pa in pas if pa.id == project_allocation_id), None)
if not pa:
raise HTTPException(status_code=404, detail="Project allocation not found")
- return await router.adapter.get_user_allocations(user, pa)
+ return await router.adapter.get_user_allocations(user=user, project_allocation=pa)
@router.get(
"/projects/{project_id}/project_allocations/{project_allocation_id}/user_allocations/{user_allocation_id}",
- dependencies=[Depends(router.current_user)],
summary="Get a user allocation of the current user's projects",
description="Get a user allocation for the currently authenticated user's projects at this facility.",
responses=DEFAULT_RESPONSES,
operation_id="getUserAllocationByProjectAllocation",
+ openapi_extra=iri_meta_dict("production", "required")
)
async def get_user_allocation(
project_id: str,
- project_allocation_id : str,
- user_allocation_id : str,
- request : Request,
- ) -> models.UserAllocation:
- user = await router.adapter.get_user(request.state.current_user_id, request.state.api_key, iri_router.get_client_ip(request))
- if not user:
- raise HTTPException(status_code=404, detail="User not found")
- projects = await router.adapter.get_projects(user)
+ project_allocation_id: str,
+ user_allocation_id: str,
+ request: Request,
+ _forbid=Depends(forbidExtraQueryParams()),
+ user: User = Depends(router.current_user),
+) -> models.UserAllocation:
+ projects = await router.adapter.get_projects(user=user)
project = next((p for p in projects if p.id == project_id), None)
if not project:
raise HTTPException(status_code=404, detail="Project not found")
- pas = await router.adapter.get_project_allocations(project, user)
+ pas = await router.adapter.get_project_allocations(project=project, user=user)
pa = next((pa for pa in pas if pa.id == project_allocation_id), None)
if not pa:
raise HTTPException(status_code=404, detail="Project allocation not found")
- uas = await router.adapter.get_user_allocations(user, pa)
+ uas = await router.adapter.get_user_allocations(user=user, project_allocation=pa)
ua = next((ua for ua in uas if ua.id == user_allocation_id), None)
if not ua:
raise HTTPException(status_code=404, detail="User allocation not found")
diff --git a/app/routers/account/facility_adapter.py b/app/routers/account/facility_adapter.py
index 78b622fd..109c60bd 100644
--- a/app/routers/account/facility_adapter.py
+++ b/app/routers/account/facility_adapter.py
@@ -1,43 +1,30 @@
from abc import abstractmethod
-from . import models as account_models
+
+from ...types.models import Capability
+from ...types.user import User
from ..iri_router import AuthenticatedAdapter
+from . import models as account_models
class FacilityAdapter(AuthenticatedAdapter):
"""
Facility-specific code is handled by the implementation of this interface.
- Use the `IRI_API_ADAPTER` environment variable (defaults to `app.demo_adapter.FacilityAdapter`)
+ Use the `IRI_API_ADAPTER` environment variable (defaults to `app.demo_adapter.FacilityAdapter`)
to install your facility adapter before the API starts.
"""
@abstractmethod
- async def get_capabilities(
- self : "FacilityAdapter",
- ) -> list[account_models.Capability]:
+ async def get_capabilities(self: "FacilityAdapter", name: str | None = None, modified_since: str | None = None, offset: int = 0, limit: int = 1000) -> list[Capability]:
pass
-
@abstractmethod
- async def get_projects(
- self : "FacilityAdapter",
- user: account_models.User
- ) -> list[account_models.Project]:
+ async def get_projects(self: "FacilityAdapter", user: User) -> list[account_models.Project]:
pass
-
@abstractmethod
- async def get_project_allocations(
- self : "FacilityAdapter",
- project: account_models.Project,
- user: account_models.User
- ) -> list[account_models.ProjectAllocation]:
+ async def get_project_allocations(self: "FacilityAdapter", project: account_models.Project, user: User) -> list[account_models.ProjectAllocation]:
pass
-
@abstractmethod
- async def get_user_allocations(
- self : "FacilityAdapter",
- user: account_models.User,
- project_allocation: account_models.ProjectAllocation,
- ) -> list[account_models.UserAllocation]:
+ async def get_user_allocations(self: "FacilityAdapter", user: User, project_allocation: account_models.ProjectAllocation) -> list[account_models.UserAllocation]:
pass
diff --git a/app/routers/account/models.py b/app/routers/account/models.py
index 6ed69ea0..e4c6d4f7 100644
--- a/app/routers/account/models.py
+++ b/app/routers/account/models.py
@@ -1,89 +1,82 @@
-from pydantic import BaseModel, computed_field, Field
-import enum
-from ... import config
+"""Models for account-related API endpoints, including users, projects, and allocations."""
+import datetime
+from pydantic import Field, computed_field, field_validator
+from ...request_context import get_url_prefix
+from ...types.base import IRIBaseModel
+from ...types.scalars import AllocationUnit
-class AllocationUnit(enum.Enum):
- node_hours = "node_hours"
- bytes = "bytes"
- inodes = "inodes"
+class Project(IRIBaseModel):
+ """A project and its users at a facility"""
-class Capability(BaseModel):
- """
- An aspect of a resource that can have an allocation.
- For example, Perlmutter nodes with GPUs
- For some resources at a facility, this will be 1 to 1 with the resource.
- It is a way to further subdivide a resource into allocatable sub-resources.
- The word "capability" is also known to users as something they need for a job to run. (eg. gpu)
- """
- id: str
- name: str
- units: list[AllocationUnit]
-
+ id: str = Field(..., description="Unique identifier of the project.", example="proj-abc123")
+ name: str = Field(..., description="Human-readable name of the project.", example="Climate Simulation")
+ description: str = Field(..., description="Detailed description of the project.", example="Research project studying atmospheric dynamics.")
+ user_ids: list[str] = Field(..., description="List of user identifiers participating in the project.", example=["user-123", "user-456"])
-class User(BaseModel):
- """A user of the facility"""
- id: str
- name: str
- api_key: str
- client_ip: str|None
- # we could expose more fields here (eg. email) but it might be against policy
+ @field_validator("last_modified", mode="before")
+ @classmethod
+ def _norm_dt_field(cls, v):
+ return cls.normalize_dt(v)
+ last_modified: datetime.datetime = Field(..., description="Timestamp of the last modification of the project.", example="2026-02-21T14:30:00Z")
-class Project(BaseModel):
- """A project and its users at a facility"""
- id: str
- name: str
- description: str
- user_ids: list[str]
+ @computed_field(description="URI to this project resource")
+ @property
+ def self_uri(self) -> str:
+ """Return the URI for this project resource."""
+ return f"{get_url_prefix()}/account/projects/{self.id}"
-class AllocationEntry(BaseModel):
+class AllocationEntry(IRIBaseModel):
"""Base class for allocations."""
- allocation: float # how much this allocation can spend
- usage: float # how much this allocation has spent
- unit: AllocationUnit
+
+ allocation: float = Field(..., description="Total allocation amount granted.", example=100000.0) # how much this allocation can spend
+ usage: float = Field(..., description="Amount of allocation consumed.", example=52342.5) # how much this allocation has spent
+ unit: AllocationUnit = Field(..., description="Unit of the allocation (e.g., node_hours, bytes).", example="node_hours")
-class ProjectAllocation(BaseModel):
+class ProjectAllocation(IRIBaseModel):
"""
- A project's allocation for a capability. (aka. repo)
- This allocation is a piece of the total allocation for the capability. (eg. 5% of the total node hours of Perlmutter GPU nodes)
- A project would at least have a storage and job repos, maybe more than 1 of each.
+ A project's allocation for a capability. (aka. repo)
+ This allocation is a piece of the total allocation for the capability. (eg. 5% of the total node hours of Perlmutter GPU nodes)
+ A project would at least have a storage and job repos, maybe more than 1 of each.
"""
- # how much this allocation can spend
- id: str
- project_id: str = Field(exclude=True)
- capability_id: str = Field(exclude=True)
- entries: list[AllocationEntry]
+ # how much this allocation can spend
+ id: str = Field(..., description="Unique identifier of the project allocation.", example="alloc-001")
+ project_id: str = Field(exclude=True, description="Internal identifier of the associated project.")
+ capability_id: str = Field(exclude=True, description="Internal identifier of the associated capability.")
+ entries: list[AllocationEntry] = Field(..., description="Allocation entries describing usage and limits.")
- @computed_field(description="The list of past events in this incident")
+ @computed_field(description="URI of the associated project resource")
@property
def project_uri(self) -> str:
- return f"{config.API_URL_ROOT}{config.API_PREFIX}{config.API_URL}/account/projects/{self.project_id}"
-
+ """Return the URI for the associated project resource."""
+ return f"{get_url_prefix()}/account/projects/{self.project_id}"
- @computed_field(description="The list of past events in this incident")
+ @computed_field(description="URI of the associated capability resource")
@property
def capability_uri(self) -> str:
- return f"{config.API_URL_ROOT}{config.API_PREFIX}{config.API_URL}/account/capabilities/{self.capability_id}"
+ """Return the URI for the associated capability."""
+ return f"{get_url_prefix()}/account/capabilities/{self.capability_id}"
-class UserAllocation(BaseModel):
+class UserAllocation(IRIBaseModel):
"""
- A user's allcation in a project.
- This allocation is a piece of the project's allocation.
+ A user's allcation in a project.
+ This allocation is a piece of the project's allocation.
"""
- id: str
- project_id: str = Field(exclude=True)
- project_allocation_id: str = Field(exclude=True)
- user_id: str
- entries: list[AllocationEntry]
+ id: str = Field(..., description="Unique identifier of the user allocation.", example="user-alloc-42")
+ project_id: str = Field(exclude=True, description="Internal identifier of the associated project.")
+ project_allocation_id: str = Field(exclude=True, description="Internal identifier of the associated project allocation.")
+ user_id: str = Field(..., description="Identifier of the user receiving this allocation.", example="user-123")
+ entries: list[AllocationEntry] = Field(..., description="Allocation entries describing usage and limits.")
- @computed_field(description="The list of past events in this incident")
+ @computed_field(description="URI of the associated project allocation")
@property
def project_allocation_uri(self) -> str:
- return f"{config.API_URL_ROOT}{config.API_PREFIX}{config.API_URL}/account/projects/{self.project_id}/project_allocations/{self.project_allocation_id}"
+ """Return the URI for the associated project allocation."""
+ return f"{get_url_prefix()}/account/projects/{self.project_id}/project_allocations/{self.project_allocation_id}"
diff --git a/app/routers/compute/compute.py b/app/routers/compute/compute.py
index a461082f..fb29dd88 100644
--- a/app/routers/compute/compute.py
+++ b/app/routers/compute/compute.py
@@ -1,17 +1,15 @@
-from typing import List, Annotated
-from fastapi import HTTPException, Request, Depends, status, Form, Query
-from . import models, facility_adapter
+"""Compute resource API router"""
+
+from fastapi import Depends, HTTPException, Query, Request, status
+
+from ...types.http import forbidExtraQueryParams
+from ...types.scalars import StrictHTTPBool
+from ...types.user import User
from .. import iri_router
from ..error_handlers import DEFAULT_RESPONSES
+from ..iri_meta import iri_meta_dict
from ..status.status import router as status_router
-
-
-async def _lookup_resource(resource_id: str):
- if status_router.adapter is None:
- return None
- return await status_router.adapter.get_resource(resource_id)
-
-
+from . import facility_adapter, models
router = iri_router.IriRouter(
facility_adapter.FacilityAdapter,
@@ -22,17 +20,19 @@ async def _lookup_resource(resource_id: str):
@router.post(
"/job/{resource_id:str}",
- dependencies=[Depends(router.current_user)],
response_model=models.Job,
response_model_exclude_unset=True,
responses=DEFAULT_RESPONSES,
operation_id="launchJob",
+ openapi_extra=iri_meta_dict("production", "required")
)
async def submit_job(
resource_id: str,
- job_spec : models.JobSpec,
- request : Request,
- ):
+ job_spec: models.JobSpec,
+ request: Request,
+ user: User = Depends(router.current_user),
+ _forbid=Depends(forbidExtraQueryParams()),
+):
"""
Submit a job on a compute resource
@@ -41,67 +41,30 @@ async def submit_job(
This command will attempt to submit a job and return its id.
"""
- user = await router.adapter.get_user(request.state.current_user_id, request.state.api_key, iri_router.get_client_ip(request))
- if not user:
- raise HTTPException(status_code=404, detail="User not found")
-
# look up the resource (todo: maybe ensure it's available)
- resource = await _lookup_resource(resource_id)
-
- # the handler can use whatever means it wants to submit the job and then fill in its id
- # see: https://exaworks.org/psij-python/docs/v/0.9.11/user_guide.html#submitting-jobs
- return await router.adapter.submit_job(resource, user, job_spec)
-
-
-@router.post(
- "/job/script/{resource_id:str}",
- dependencies=[Depends(router.current_user)],
- response_model=models.Job,
- response_model_exclude_unset=True,
- responses=DEFAULT_RESPONSES,
- operation_id="launchJobScript",
-)
-async def submit_job_path(
- resource_id: str,
- job_script_path : str,
- request : Request,
- args : Annotated[List[str], Form()] = [],
- ):
- """
- Submit a job on a compute resource
-
- - **resource**: the name of the compute resource to use
- - **job_script_path**: path to the job script on the compute resource
- - **args**: optional arguments to the job script
+ resource = await status_router.adapter.get_resource(resource_id)
- This command will attempt to submit a job and return its id.
- """
- user = await router.adapter.get_user(request.state.current_user_id, request.state.api_key, iri_router.get_client_ip(request))
- if not user:
- raise HTTPException(status_code=404, detail="User not found")
-
- # look up the resource (todo: maybe ensure it's available)
- resource = await _lookup_resource(resource_id)
-
# the handler can use whatever means it wants to submit the job and then fill in its id
# see: https://exaworks.org/psij-python/docs/v/0.9.11/user_guide.html#submitting-jobs
- return await router.adapter.submit_job_script(resource, user, job_script_path, args)
+ return await router.adapter.submit_job(resource=resource, user=user, job_spec=job_spec)
@router.put(
"/job/{resource_id:str}/{job_id:str}",
- dependencies=[Depends(router.current_user)],
response_model=models.Job,
response_model_exclude_unset=True,
responses=DEFAULT_RESPONSES,
operation_id="updateJob",
+ openapi_extra=iri_meta_dict("production", "required")
)
async def update_job(
resource_id: str,
job_id: str,
- job_spec : models.JobSpec,
- request : Request,
- ):
+ job_spec: models.JobSpec,
+ request: Request,
+ user: User = Depends(router.current_user),
+ _forbid=Depends(forbidExtraQueryParams()),
+):
"""
Update a previously submitted job for a resource.
Note that only some attributes of a scheduled job can be updated. Check the facility documentation for details.
@@ -110,102 +73,90 @@ async def update_job(
- **job_request**: a PSIJ job spec as defined here
"""
- user = await router.adapter.get_user(request.state.current_user_id, request.state.api_key, iri_router.get_client_ip(request))
- if not user:
- raise HTTPException(status_code=404, detail="User not found")
-
# look up the resource (todo: maybe ensure it's available)
- resource = await _lookup_resource(resource_id)
+ resource = await status_router.adapter.get_resource(resource_id)
# the handler can use whatever means it wants to submit the job and then fill in its id
# see: https://exaworks.org/psij-python/docs/v/0.9.11/user_guide.html#submitting-jobs
- return await router.adapter.update_job(resource, user, job_spec, job_id)
+ return await router.adapter.update_job(resource=resource, user=user, job_spec=job_spec, job_id=job_id)
@router.get(
"/status/{resource_id:str}/{job_id:str}",
- dependencies=[Depends(router.current_user)],
response_model=models.Job,
response_model_exclude_unset=True,
responses=DEFAULT_RESPONSES,
- operation_id="getJobs",
+ operation_id="getJob",
+ openapi_extra=iri_meta_dict("production", "required")
)
async def get_job_status(
- resource_id : str,
- job_id : str,
- request : Request,
- historical : bool = False,
- include_spec: bool = False,
- ):
+ resource_id: str,
+ job_id: str,
+ request: Request,
+ user: User = Depends(router.current_user),
+ historical: StrictHTTPBool | None = Query(default=True, description="Whether to include historical jobs. Defaults to true"),
+ include_spec: StrictHTTPBool | None = Query(default=False, description="Whether to include the job specification. Defaults to false"),
+ _forbid=Depends(forbidExtraQueryParams("historical", "include_spec")),
+):
"""Get a job's status"""
- user = await router.adapter.get_user(request.state.current_user_id, request.state.api_key, iri_router.get_client_ip(request))
- if not user:
- raise HTTPException(status_code=404, detail="User not found")
-
# look up the resource (todo: maybe ensure it's available)
# This could be done via slurm (in the adapter) or via psij's "attach" (https://exaworks.org/psij-python/docs/v/0.9.11/user_guide.html#detaching-and-attaching-jobs)
- resource = await _lookup_resource(resource_id)
+ resource = await status_router.adapter.get_resource(resource_id)
- job = await router.adapter.get_job(resource, user, job_id, historical, include_spec)
+ job = await router.adapter.get_job(resource=resource, user=user, job_id=job_id, historical=historical, include_spec=include_spec)
return job
@router.post(
"/status/{resource_id:str}",
- dependencies=[Depends(router.current_user)],
response_model=list[models.Job],
response_model_exclude_unset=True,
responses=DEFAULT_RESPONSES,
operation_id="getJobs",
+ openapi_extra=iri_meta_dict("production", "required")
)
async def get_job_statuses(
- resource_id : str,
- request : Request,
- offset : int = Query(default=0, ge=0),
- limit : int = Query(default=100, le=10000),
- filters : dict[str, object] | None = None,
- historical : bool = False,
- include_spec: bool = False,
- ):
+ resource_id: str,
+ request: Request,
+ user: User = Depends(router.current_user),
+ offset: int = Query(default=0, ge=0),
+ limit: int = Query(default=100, ge=0, le=1000),
+ filters: dict[str, object] | None = None,
+ historical: StrictHTTPBool | None = Query(default=False, description="Whether to include historical jobs. Defaults to false"),
+ include_spec: StrictHTTPBool | None = Query(default=False, description="Whether to include the job specification. Defaults to false"),
+ _forbid=Depends(forbidExtraQueryParams("offset", "limit", "filters", "historical", "include_spec")),
+):
"""Get multiple jobs' statuses"""
- user = await router.adapter.get_user(request.state.current_user_id, request.state.api_key, iri_router.get_client_ip(request))
- if not user:
- raise HTTPException(status_code=404, detail="User not found")
-
# look up the resource (todo: maybe ensure it's available)
# This could be done via slurm (in the adapter) or via psij's "attach" (https://exaworks.org/psij-python/docs/v/0.9.11/user_guide.html#detaching-and-attaching-jobs)
- resource = await _lookup_resource(resource_id)
+ resource = await status_router.adapter.get_resource(resource_id)
- jobs = await router.adapter.get_jobs(resource, user, offset, limit, filters, historical, include_spec)
+ jobs = await router.adapter.get_jobs(resource=resource, user=user, offset=offset, limit=limit, filters=filters, historical=historical, include_spec=include_spec)
return jobs
@router.delete(
"/cancel/{resource_id:str}/{job_id:str}",
- dependencies=[Depends(router.current_user)],
status_code=status.HTTP_204_NO_CONTENT,
response_model=None,
response_model_exclude_unset=True,
responses=DEFAULT_RESPONSES,
operation_id="cancelJob",
+ openapi_extra=iri_meta_dict("production", "required")
)
async def cancel_job(
- resource_id : str,
- job_id : str,
- request : Request,
- ):
+ resource_id: str,
+ job_id: str,
+ request: Request,
+ user: User = Depends(router.current_user),
+ _forbid=Depends(forbidExtraQueryParams()),
+):
"""Cancel a job"""
- user = await router.adapter.get_user(request.state.current_user_id, request.state.api_key, iri_router.get_client_ip(request))
- if not user:
- raise HTTPException(status_code=404, detail="User not found")
-
# look up the resource (todo: maybe ensure it's available)
- resource = await _lookup_resource(resource_id)
+ resource = await status_router.adapter.get_resource(resource_id)
+
+ await router.adapter.cancel_job(resource=resource, user=user, job_id=job_id)
- try:
- await router.adapter.cancel_job(resource, user, job_id)
- except Exception as exc:
- raise HTTPException(status_code=400, detail=f"Unable to cancel job: {str(exc)}") from exc
- return None
+ return None
\ No newline at end of file
diff --git a/app/routers/compute/facility_adapter.py b/app/routers/compute/facility_adapter.py
index 6cf0bb2f..32adbdbf 100644
--- a/app/routers/compute/facility_adapter.py
+++ b/app/routers/compute/facility_adapter.py
@@ -1,6 +1,6 @@
from abc import abstractmethod
+from ...types.user import User
from ..status import models as status_models
-from ..account import models as account_models
from . import models as compute_models
from ..iri_router import AuthenticatedAdapter
@@ -12,70 +12,38 @@ class FacilityAdapter(AuthenticatedAdapter):
to install your facility adapter before the API starts.
"""
-
@abstractmethod
- async def submit_job(
- self: "FacilityAdapter",
- resource: status_models.Resource,
- user: account_models.User,
- job_spec: compute_models.JobSpec,
- ) -> compute_models.Job:
+ async def submit_job(self: "FacilityAdapter", resource: status_models.Resource, user: User, job_spec: compute_models.JobSpec) -> compute_models.Job:
pass
-
@abstractmethod
- async def submit_job_script(
- self: "FacilityAdapter",
- resource: status_models.Resource,
- user: account_models.User,
- job_script_path: str,
- args: list[str] = [],
- ) -> compute_models.Job:
+ async def update_job(self: "FacilityAdapter", resource: status_models.Resource, user: User, job_spec: compute_models.JobSpec, job_id: str) -> compute_models.Job:
pass
-
- @abstractmethod
- async def update_job(
- self: "FacilityAdapter",
- resource: status_models.Resource,
- user: account_models.User,
- job_spec: compute_models.JobSpec,
- job_id: str,
- ) -> compute_models.Job:
- pass
-
-
@abstractmethod
async def get_job(
self: "FacilityAdapter",
resource: status_models.Resource,
- user: account_models.User,
+ user: User,
job_id: str,
- historical: bool = False,
+ historical: bool = True,
include_spec: bool = False,
) -> compute_models.Job:
pass
-
@abstractmethod
async def get_jobs(
self: "FacilityAdapter",
resource: status_models.Resource,
- user: account_models.User,
- offset : int,
- limit : int,
+ user: User,
+ offset: int,
+ limit: int,
filters: dict[str, object] | None = None,
historical: bool = False,
include_spec: bool = False,
) -> list[compute_models.Job]:
pass
-
@abstractmethod
- async def cancel_job(
- self: "FacilityAdapter",
- resource: status_models.Resource,
- user: account_models.User,
- job_id: str,
- ) -> bool:
+ async def cancel_job(self: "FacilityAdapter", resource: status_models.Resource, user: User, job_id: str) -> bool:
pass
diff --git a/app/routers/compute/models.py b/app/routers/compute/models.py
index 35d34ef9..cea26492 100644
--- a/app/routers/compute/models.py
+++ b/app/routers/compute/models.py
@@ -1,51 +1,91 @@
+"""Models for compute router, including job specifications, job status, and related data structures."""
+from enum import Enum
from typing import Annotated
-from pydantic import BaseModel, field_serializer, ConfigDict, Field
-import datetime
-from enum import IntEnum
+from pydantic import ConfigDict, Field, StrictBool
-class ResourceSpec(BaseModel):
- node_count: int | None = None
- process_count: int | None = None
- processes_per_node: int | None = None
- cpu_cores_per_process: int | None = None
- gpu_cores_per_process: int | None = None
- exclusive_node_use: bool = True
- memory: int | None = None
+from ...types.base import IRIBaseModel
-class JobAttributes(BaseModel):
- duration: Annotated[int | None, Field(description="Duration in seconds", ge=0, examples=[30, 60, 120])] = None
- queue_name: str | None = None
- account: str | None = None
- reservation_id: str | None = None
- custom_attributes: dict[str, str] = {}
+class ResourceSpec(IRIBaseModel):
+ """
+ Specification of computational resources required for a job.
+ """
+ node_count: int|None = Field(default=None, ge=1, description="Number of compute nodes to allocate", example=2)
+ process_count: int|None = Field(default=None, ge=1, description="Total number of processes to launch", example=64)
+ processes_per_node: int|None = Field(default=None, ge=1, description="Number of processes to launch per node", example=32)
+ cpu_cores_per_process: int|None = Field(default=None, ge=1, description="Number of CPU cores to allocate per process", example=2)
+ gpu_cores_per_process: int|None = Field(default=None, ge=1, description="Number of GPU cores to allocate per process", example=1)
+ exclusive_node_use: StrictBool = Field(default=True, description="Whether to request exclusive use of allocated nodes", example=True)
+ memory: int|None = Field(default=None, ge=1, description="Amount of memory to allocate in bytes", example=17179869184)
-class JobSpec(BaseModel):
- model_config = ConfigDict(extra="forbid")
- executable : str | None = None
- arguments: list[str] = []
- directory: str | None = None
- name: str | None = None
- inherit_environment: bool = True
- environment: dict[str, str] = {}
- stdin_path: str | None = None
- stdout_path: str | None = None
- stderr_path: str | None = None
- resources: ResourceSpec | None = None
- attributes: JobAttributes | None = None
- pre_launch: str | None = None
- post_launch: str | None = None
- launcher: str | None = None
+
+class JobAttributes(IRIBaseModel):
+ """
+ Additional attributes and scheduling parameters for a job.
+ """
+
+ duration: int|None = Field(default=None, description="Duration in seconds", ge=1, examples=[30, 60, 120])
+ queue_name: str|None = Field(default=None, min_length=1, description="Name of the queue or partition to submit the job to", example="debug")
+ account: str|None = Field(default=None, min_length=1, description="Account or project to charge for resource usage", example="proj123")
+ reservation_id: str|None = Field(default=None, min_length=1, description="ID of a reservation to use for the job", example="resv-42")
+ custom_attributes: dict[str, str] = Field(default_factory=dict, description="Custom scheduler-specific attributes as key-value pairs", example={"constraint": "gpu"})
+
+
+class VolumeMount(IRIBaseModel):
+ """
+ Represents a volume mount for a container.
+ """
+
+ source: str = Field(min_length=1, description="The source path on the host system to mount", example="/data/project")
+ target: str = Field(min_length=1, description="The target path inside the container where the volume will be mounted", example="/mnt/data")
+ read_only: StrictBool = Field(default=True, description="Whether the mount should be read-only", example=True)
-class CommandResult(BaseModel):
- status : str
- result : str | None = None
+class Container(IRIBaseModel):
+ """
+ Represents a container specification for job execution.
+
+ Implementation notes: The value of gpu_cores_per_process in ResourceSpec should be used to determine
+ if the container should be run with GPU support. Likewise, the value of launcher in JobSpec should be used
+ to determine if the container should be run with MPI support. The container should by default. be run with
+ host networking.
+ """
+ image: str = Field(min_length=1, description="The container image to use (e.g., 'docker.io/library/ubuntu:latest')", example="docker.io/library/ubuntu:latest")
+ volume_mounts: list[VolumeMount] = Field(default_factory=list, description="List of volume mounts for the container")
+
+
+class JobSpec(IRIBaseModel):
+ """
+ Specification for a job.
+ """
-class JobState(IntEnum):
+ model_config = ConfigDict(extra="forbid")
+ executable: str|None = Field(default=None,
+ min_length=1,
+ description="Path to the executable to run. If container is specified, this will be used as the entrypoint to the container.",
+ example="/usr/bin/python")
+ container: Container|None = Field(default=None, description="Container specification for containerized execution")
+ arguments: list[str] = Field(default_factory=list, description="Command-line arguments to pass to the executable or container", example=["-n", "100"])
+ directory: str|None = Field(default=None, min_length=1, description="Working directory for the job", example="/home/user/work")
+ name: str|None = Field(default=None, min_length=1, description="Name of the job", example="my-job")
+ inherit_environment: StrictBool = Field(default=True, description="Whether to inherit the environment variables from the submission environment", example=True)
+ environment: dict[str, str] = Field(default_factory=dict,
+ description="Environment variables to set for the job. If container is specified, these will be set inside the container.",
+ example={"OMP_NUM_THREADS": "4"})
+ stdin_path: str|None = Field(default=None, min_length=1, description="Path to file to use as standard input", example="/home/user/input.txt")
+ stdout_path: str|None = Field(default=None, min_length=1, description="Path to file to write standard output", example="/home/user/output.txt")
+ stderr_path: str|None = Field(default=None, min_length=1, description="Path to file to write standard error", example="/home/user/error.txt")
+ resources: ResourceSpec|None = Field(default=None, description="Resource requirements for the job")
+ attributes: JobAttributes|None = Field(default=None, description="Additional job attributes such as duration, queue, and account")
+ pre_launch: str|None = Field(default=None, min_length=1, description="Script or commands to run before launching the job", example="module load cuda")
+ post_launch: str|None = Field(default=None, min_length=1, description="Script or commands to run after the job completes", example="echo done")
+ launcher: str|None = Field(default=None, min_length=1, description="Job launcher to use (e.g., 'mpirun', 'srun')", example="srun")
+
+
+class JobState(str, Enum):
"""
from: https://exaworks.org/psij-python/docs/v/0.9.11/_modules/psij/job_state.html#JobState
@@ -54,46 +94,47 @@ class JobState(IntEnum):
The possible states are: `NEW`, `QUEUED`, `ACTIVE`, `COMPLETED`, `FAILED`, and `CANCELED`.
"""
- NEW = 0
+ NEW = "new"
"""
This is the state of a job immediately after the :class:`~psij.Job` object is created and
before being submitted to a :class:`~psij.JobExecutor`.
"""
- QUEUED = 1
+ QUEUED = "queued"
"""
This is the state of the job after being accepted by a backend for execution, but before the
execution of the job begins.
"""
- ACTIVE = 2
+ HELD = "held"
+ """
+ This is the state of a job that is queued but ineligible to run.
+ """
+ ACTIVE = "active"
"""This state represents an actively running job."""
- COMPLETED = 3
+ COMPLETED = "completed"
"""
This state represents a job that has completed *successfully* (i.e., with a zero exit code).
In other words, a job with the executable set to `/bin/false` cannot enter this state.
"""
- FAILED = 4
+ FAILED = "failed"
"""
Represents a job that has either completed unsuccessfully (with a non-zero exit code) or a job
whose handling and/or execution by the backend has failed in some way.
"""
- CANCELED = 5
+ CANCELED = "canceled"
"""Represents a job that was canceled by a call to :func:`~psij.Job.cancel()`."""
-class JobStatus(BaseModel):
- state : JobState
- time : float | None = None
- message : str | None = None
- exit_code : int | None = None
- meta_data : dict[str, object] | None = None
-
-
- @field_serializer('state')
- def serialize_state(self, state: JobState):
- return state.name
+class JobStatus(IRIBaseModel):
+ """Represents the status of a job."""
+ state: JobState = Field(..., description="Current state of the job", example="queued")
+ time: float|None = Field(default=None, description="Timestamp associated with the status (seconds since epoch)", example=1708531200.0)
+ message: str|None = Field(default=None, description="Human-readable status message", example="Job is waiting in queue")
+ exit_code: int|None = Field(default=None, description="Process exit code if the job has finished", example=0)
+ meta_data: dict[str, object]|None = Field(default=None, description="Backend-specific metadata associated with the job status")
-class Job(BaseModel):
- id : str
- status : JobStatus | None = None
- job_spec : JobSpec | None = None
+class Job(IRIBaseModel):
+ """Represents a job in the system."""
+ id: str = Field(..., description="Unique identifier of the job", example="job-12345")
+ status: JobStatus|None = Field(default=None, description="Current status of the job")
+ job_spec: JobSpec|None = Field(default=None, description="Specification used to create the job")
diff --git a/app/routers/error_handlers.py b/app/routers/error_handlers.py
index 09769ec2..a94334da 100644
--- a/app/routers/error_handlers.py
+++ b/app/routers/error_handlers.py
@@ -2,26 +2,69 @@
"""
Default problem schema and example responses for various HTTP status codes.
"""
+
import logging
-from urllib.parse import unquote
+from urllib.parse import urlsplit, urlunsplit, quote
+
+from pydantic import BaseModel, Field, ConfigDict
+
from fastapi import FastAPI, HTTPException, Request
-from fastapi.responses import JSONResponse
+from fastapi.responses import JSONResponse, Response
from fastapi.exceptions import RequestValidationError
from starlette.exceptions import HTTPException as StarletteHTTPException
+
+class Problem(BaseModel):
+ model_config = ConfigDict(extra="allow", json_schema_extra={"description": 'Error structure for REST interface based on RFC 9457, "Problem Details for HTTP APIs."'})
+ type: str = Field(..., description="A URI reference that identifies the problem type.", example="https://example.com/notFound", json_schema_extra={"format": "uri", "default": "about:blank"})
+ status: int = Field(..., ge=100, le=599, description="The HTTP status code for this occurrence.", example=404)
+ title: str|None = Field(default=None, description="Short human-readable summary.", example="Not Found")
+ detail: str|None = Field(default=None, description="Human-readable explanation.", example="Descriptive text.")
+ instance: str = Field(..., description="A URI reference identifying this occurrence.", example="http://localhost/api/v1/resource/123")
+
+
def get_url_base(request: Request) -> str:
"""Return the base URL for the API."""
# If behind a proxy (and x-forwarded-* headers present), use the forwarded host and protocol
- host = request.headers.get("x-forwarded-host") or request.headers.get("host")
- proto = request.headers.get("x-forwarded-proto") or request.url.scheme
+ host = (request.headers.get("x-forwarded-host") or request.headers.get("host", "")).split(",")[0].strip()
+ proto = (request.headers.get("x-forwarded-proto") or request.url.scheme).split(",")[0].strip()
return f"{proto}://{host}/problems"
-def problem_response(*, request: Request, status: int,
- title: str, detail: str, problem_type: str,
- invalid_params=None, extra_headers=None):
+
+def safe_instance_url(request: Request) -> str:
+ """Return a URL-safe version of the request URL for the 'instance' field."""
+ parts = urlsplit(str(request.url))
+
+ # Encode unsafe characters in each component
+ safe_path = quote(parts.path, safe="/:@&+$,;=-._~")
+ safe_query = quote(parts.query, safe="=&?/:@+$,;=-._~")
+ safe_fragment = quote(parts.fragment, safe="=&?/:@+$,;=-._~")
+
+ return urlunsplit((parts.scheme, parts.netloc, safe_path, safe_query, safe_fragment))
+
+
+def problem_response(*, request: Request, status: int, title, detail, problem_type: str, invalid_params=None, extra_headers=None):
"""Return a JSON problem response with the given status, title, and detail."""
- instance = unquote(str(request.url))
+ instance = safe_instance_url(request)
url_base = get_url_base(request)
+
+ # Normalize title and detail to strings (Official spec says they must be strings)
+ # but fastapi validation errors may provide lists/dicts
+ if not isinstance(title, str):
+ if status >= 500:
+ title = "Internal Server Error"
+ elif status >= 400:
+ title = "Bad Request"
+ else:
+ title = "Error"
+
+
+ if not isinstance(detail, str):
+ if isinstance(detail, list):
+ detail = ", ".join(err.get("msg", str(err)) if isinstance(err, dict) else str(err) for err in detail)
+ else:
+ detail = str(detail)
+
body = {
"type": f"{url_base}/{problem_type}",
"title": title,
@@ -34,19 +77,19 @@ def problem_response(*, request: Request, status: int,
body["invalid_params"] = invalid_params
headers = extra_headers or {}
- return JSONResponse(status_code=status, content=body, headers=headers)
+ return JSONResponse(status_code=status, content=Problem(**body).model_dump(), headers=headers, media_type="application/problem+json")
def install_error_handlers(app: FastAPI):
"""Install custom error handlers for the FastAPI app."""
+
# 400 — VALIDATION ERRORS
@app.exception_handler(RequestValidationError)
async def validation_error_handler(request: Request, exc: RequestValidationError):
invalid_params = []
for err in exc.errors():
- loc = err.get("loc", [])
- name = loc[-1] if loc else "unknown"
+ name = str((err.get("loc") or ["unknown"])[-1])
reason = err.get("msg", "Invalid parameter")
invalid_params.append({"name": name, "reason": reason})
@@ -64,13 +107,19 @@ async def validation_error_handler(request: Request, exc: RequestValidationError
# FASTAPI HTTP EXCEPTIONS
@app.exception_handler(HTTPException)
async def http_exception_handler(request: Request, exc: HTTPException):
+ err_msg = ""
+ if hasattr(exc, "detail") and exc.detail:
+ err_msg = exc.detail
+
+ if exc.status_code == 304:
+ return Response(status_code=304, headers=exc.headers or {})
if exc.status_code == 401:
return problem_response(
request=request,
status=401,
title="Unauthorized",
- detail="Bearer token is missing or invalid.",
+ detail=err_msg or "Bearer token is missing or invalid.",
problem_type="unauthorized",
extra_headers={"WWW-Authenticate": "Bearer"},
)
@@ -80,7 +129,7 @@ async def http_exception_handler(request: Request, exc: HTTPException):
request=request,
status=403,
title="Forbidden",
- detail="Caller is authenticated but lacks required role.",
+ detail=err_msg or "Caller is authenticated but lacks required role.",
problem_type="forbidden",
)
@@ -89,7 +138,7 @@ async def http_exception_handler(request: Request, exc: HTTPException):
request=request,
status=404,
title="Not Found",
- detail=exc.detail or "Invalid resource identifier.",
+ detail=err_msg or "Invalid resource identifier.",
problem_type="not-found",
)
@@ -98,7 +147,7 @@ async def http_exception_handler(request: Request, exc: HTTPException):
request=request,
status=405,
title="Method Not Allowed",
- detail="HTTP method is not allowed for this resource.",
+ detail=err_msg or "HTTP method is not allowed for this resource.",
problem_type="method-not-allowed",
extra_headers={"Allow": "GET, HEAD"},
)
@@ -108,7 +157,7 @@ async def http_exception_handler(request: Request, exc: HTTPException):
request=request,
status=409,
title="Conflict",
- detail=exc.detail or "Conflict occurred.",
+ detail=err_msg or "Conflict occurred.",
problem_type="conflict",
)
@@ -116,21 +165,24 @@ async def http_exception_handler(request: Request, exc: HTTPException):
return problem_response(
request=request,
status=exc.status_code,
- title=exc.detail or "Error",
- detail=exc.detail or "An error occurred.",
+ title="Error",
+ detail=err_msg or "An error occurred.",
problem_type="generic-error",
)
# STARLETTE HTTP EXCEPTIONS
@app.exception_handler(StarletteHTTPException)
async def starlette_handler(request: Request, exc: StarletteHTTPException):
+ err_msg = ""
+ if hasattr(exc, "detail") and exc.detail:
+ err_msg = exc.detail
if exc.status_code == 404:
return problem_response(
request=request,
status=404,
title="Not Found",
- detail="Invalid resource identifier.",
+ detail=err_msg or "Invalid resource identifier.",
problem_type="not-found",
)
@@ -139,7 +191,7 @@ async def starlette_handler(request: Request, exc: StarletteHTTPException):
request=request,
status=405,
title="Method Not Allowed",
- detail="HTTP method is not allowed for this resource.",
+ detail=err_msg or "HTTP method is not allowed for this resource.",
problem_type="method-not-allowed",
extra_headers={"Allow": "GET, HEAD"},
)
@@ -147,8 +199,8 @@ async def starlette_handler(request: Request, exc: StarletteHTTPException):
return problem_response(
request=request,
status=exc.status_code,
- title=exc.detail or "Error",
- detail=exc.detail or "An error occurred.",
+ title="Error",
+ detail=err_msg or "An error occurred.",
problem_type="generic-error",
)
@@ -165,54 +217,23 @@ async def global_handler(request: Request, exc: Exception):
)
-DEFAULT_PROBLEM_SCHEMA = {
- "type": "object",
- "properties": {
- "type": {"type": "string"},
- "title": {"type": "string"},
- "status": {"type": "integer"},
- "detail": {"type": "string"},
- "instance": {"type": "string"},
- "invalid_params": {
- "type": "array",
- "items": {
- "type": "object",
- "properties": {
- "name": {"type": "string"},
- "reason": {"type": "string"},
- },
- "required": ["name", "reason"],
- },
- },
- },
- "required": ["type", "title", "status", "detail", "instance"],
-}
-
EXAMPLE_400 = {
"type": "https://iri.example.com/problems/invalid-parameter",
"title": "Invalid parameter",
"status": 400,
"detail": "modified_since must be in ISO 8601 format.",
"instance": "/api/v1/status/resources?modified_since=BADVALUE",
- "invalid_params": [
- {"name": "modified_since", "reason": "Invalid datetime format"}
- ]
+ "invalid_params": [{"name": "modified_since", "reason": "Invalid datetime format"}],
}
-EXAMPLE_401 = {
- "type": "https://iri.example.com/problems/unauthorized",
- "title": "Unauthorized",
- "status": 401,
- "detail": "Bearer token is missing or invalid.",
- "instance": "/api/v1/status/resources"
-}
+EXAMPLE_401 = {"type": "https://iri.example.com/problems/unauthorized", "title": "Unauthorized", "status": 401, "detail": "Bearer token is missing or invalid.", "instance": "/api/v1/status/resources"}
EXAMPLE_403 = {
"type": "https://iri.example.com/problems/forbidden",
"title": "Forbidden",
"status": 403,
"detail": "Caller is authenticated but lacks required role.",
- "instance": "/api/v1/status/resources"
+ "instance": "/api/v1/status/resources",
}
EXAMPLE_404 = {
@@ -220,7 +241,7 @@ async def global_handler(request: Request, exc: Exception):
"title": "Not Found",
"status": 404,
"detail": "The resource ID 'abc123' does not exist.",
- "instance": "/api/v1/status/resources/abc123"
+ "instance": "/api/v1/status/resources/abc123",
}
EXAMPLE_405 = {
@@ -228,7 +249,7 @@ async def global_handler(request: Request, exc: Exception):
"title": "Method Not Allowed",
"status": 405,
"detail": "HTTP method TRACE is not allowed for this endpoint.",
- "instance": "/api/v1/status/resources"
+ "instance": "/api/v1/status/resources",
}
EXAMPLE_409 = {
@@ -236,7 +257,7 @@ async def global_handler(request: Request, exc: Exception):
"title": "Conflict",
"status": 409,
"detail": "A job with this ID already exists.",
- "instance": "/api/v1/compute/job/perlmutter/123"
+ "instance": "/api/v1/compute/job/perlmutter/123",
}
EXAMPLE_422 = {
@@ -245,9 +266,7 @@ async def global_handler(request: Request, exc: Exception):
"status": 422,
"detail": "The PSIJ JobSpec is syntactically correct but invalid.",
"instance": "/api/v1/compute/job/perlmutter",
- "invalid_params": [
- {"name": "job_spec.executable", "reason": "Executable must be provided"}
- ]
+ "invalid_params": [{"name": "job_spec.executable", "reason": "Executable must be provided"}],
}
EXAMPLE_500 = {
@@ -255,7 +274,7 @@ async def global_handler(request: Request, exc: Exception):
"title": "Internal Server Error",
"status": 500,
"detail": "An unexpected error occurred.",
- "instance": "/api/v1/status/resources"
+ "instance": "/api/v1/status/resources",
}
EXAMPLE_501 = {
@@ -263,7 +282,7 @@ async def global_handler(request: Request, exc: Exception):
"title": "Not Implemented",
"status": 501,
"detail": "This functionality is not implemented.",
- "instance": "/api/v1/status/resources"
+ "instance": "/api/v1/status/resources",
}
EXAMPLE_503 = {
@@ -271,7 +290,7 @@ async def global_handler(request: Request, exc: Exception):
"title": "Service Unavailable",
"status": 503,
"detail": "The service is temporarily unavailable.",
- "instance": "/api/v1/status/resources"
+ "instance": "/api/v1/status/resources",
}
EXAMPLE_504 = {
@@ -279,20 +298,14 @@ async def global_handler(request: Request, exc: Exception):
"title": "Gateway Timeout",
"status": 504,
"detail": "The server did not receive a timely response.",
- "instance": "/api/v1/status/resources"
+ "instance": "/api/v1/status/resources",
}
DEFAULT_RESPONSES = {
400: {
"description": "Invalid request parameters",
- "content": {
- "application/problem+json": {
- "schema": DEFAULT_PROBLEM_SCHEMA,
- "example": EXAMPLE_400,
- }
- },
+ "model": Problem,
},
-
401: {
"description": "Unauthorized",
"headers": {
@@ -301,34 +314,17 @@ async def global_handler(request: Request, exc: Exception):
"schema": {"type": "string"},
}
},
- "content": {
- "application/problem+json": {
- "schema": DEFAULT_PROBLEM_SCHEMA,
- "example": EXAMPLE_401,
- }
- },
- },
+ "model": Problem,
+ },
403: {
"description": "Forbidden",
- "content": {
- "application/problem+json": {
- "schema": DEFAULT_PROBLEM_SCHEMA,
- "example": EXAMPLE_403,
- }
- },
+ "model": Problem,
},
-
404: {
"description": "Not Found",
- "content": {
- "application/problem+json": {
- "schema": DEFAULT_PROBLEM_SCHEMA,
- "example": EXAMPLE_404,
- }
- },
+ "model": Problem,
},
-
405: {
"description": "Method Not Allowed",
"headers": {
@@ -337,73 +333,31 @@ async def global_handler(request: Request, exc: Exception):
"schema": {"type": "string"},
}
},
- "content": {
- "application/problem+json": {
- "schema": DEFAULT_PROBLEM_SCHEMA,
- "example": EXAMPLE_405,
- }
- },
+ "model": Problem,
},
-
409: {
"description": "Conflict",
- "content": {
- "application/problem+json": {
- "schema": DEFAULT_PROBLEM_SCHEMA,
- "example": EXAMPLE_409,
- }
- },
+ "model": Problem,
},
-
422: {
"description": "Unprocessable Entity",
- "content": {
- "application/problem+json": {
- "schema": DEFAULT_PROBLEM_SCHEMA,
- "example": EXAMPLE_422,
- }
- },
+ "model": Problem,
},
-
500: {
"description": "Internal Server Error",
- "content": {
- "application/problem+json": {
- "schema": DEFAULT_PROBLEM_SCHEMA,
- "example": EXAMPLE_500,
- }
- },
+ "model": Problem,
},
-
501: {
"description": "Not Implemented",
- "content": {
- "application/problem+json": {
- "schema": DEFAULT_PROBLEM_SCHEMA,
- "example": EXAMPLE_501,
- }
- }
+ "model": Problem,
},
-
503: {
"description": "Service Unavailable",
- "content": {
- "application/problem+json": {
- "schema": DEFAULT_PROBLEM_SCHEMA,
- "example": EXAMPLE_503,
- }
- }
+ "model": Problem,
},
-
504: {
"description": "Gateway Timeout",
- "content": {
- "application/problem+json": {
- "schema": DEFAULT_PROBLEM_SCHEMA,
- "example": EXAMPLE_504,
- }
- }
+ "model": Problem,
},
-
304: {"description": "Not Modified"},
-}
+}
\ No newline at end of file
diff --git a/app/routers/facility/__init__.py b/app/routers/facility/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/app/routers/facility/facility.py b/app/routers/facility/facility.py
new file mode 100644
index 00000000..f86cd9df
--- /dev/null
+++ b/app/routers/facility/facility.py
@@ -0,0 +1,63 @@
+from fastapi import Depends, Query, Request, HTTPException
+
+from ...types.http import forbidExtraQueryParams
+from ...types.scalars import StrictDateTime
+from .. import iri_router
+from ..error_handlers import DEFAULT_RESPONSES
+from ..iri_meta import iri_meta_dict
+from . import facility_adapter, models
+
+router = iri_router.IriRouter(facility_adapter.FacilityAdapter, prefix="/facility", tags=["facility"])
+
+
+@router.get("",
+ responses=DEFAULT_RESPONSES,
+ operation_id="getFacility",
+ response_model_exclude_none=True,
+ openapi_extra=iri_meta_dict("production", "required"))
+@router.get("/",
+ responses=DEFAULT_RESPONSES,
+ operation_id="getFacilityWithSlash",
+ response_model_exclude_none=True,
+ include_in_schema=False)
+async def get_facility(
+ request: Request,
+ modified_since: StrictDateTime = Query(default=None),
+ _forbid=Depends(forbidExtraQueryParams("modified_since")),
+) -> models.Facility:
+ """Get facility information"""
+ facility = await router.adapter.get_facility(modified_since=modified_since)
+ if not facility:
+ raise HTTPException(status_code=404, detail="Facility not found")
+ return facility
+
+
+@router.get("/sites", responses=DEFAULT_RESPONSES, operation_id="getSites", response_model_exclude_none=True, openapi_extra=iri_meta_dict("production", "required"))
+async def list_sites(
+ request: Request,
+ modified_since: StrictDateTime = Query(default=None),
+ name: str | None = Query(default=None, min_length=1),
+ offset: int = Query(default=0, ge=0),
+ limit: int = Query(default=100, ge=0, le=1000),
+ short_name: str | None = Query(default=None, min_length=1),
+ _forbid=Depends(forbidExtraQueryParams("modified_since", "name", "offset", "limit", "short_name")),
+) -> list[models.Site]:
+ """List sites"""
+ sites = await router.adapter.list_sites(modified_since=modified_since, name=name, offset=offset, limit=limit, short_name=short_name)
+ if not sites:
+ raise HTTPException(status_code=404, detail="No sites found")
+ return sites
+
+
+@router.get("/sites/{site_id}", responses=DEFAULT_RESPONSES, operation_id="getSite", response_model_exclude_none=True, openapi_extra=iri_meta_dict("production", "required"))
+async def get_site(
+ request: Request,
+ site_id: str,
+ modified_since: StrictDateTime = Query(default=None),
+ _forbid=Depends(forbidExtraQueryParams("modified_since")),
+) -> models.Site:
+ """Get site by ID"""
+ site = await router.adapter.get_site(site_id=site_id, modified_since=modified_since)
+ if not site:
+ raise HTTPException(status_code=404, detail="Site not found")
+ return site
diff --git a/app/routers/facility/facility_adapter.py b/app/routers/facility/facility_adapter.py
new file mode 100644
index 00000000..e43de0ea
--- /dev/null
+++ b/app/routers/facility/facility_adapter.py
@@ -0,0 +1,28 @@
+from abc import ABC, abstractmethod
+from . import models as facility_models
+
+
+class FacilityAdapter(ABC):
+ """
+ Facility-specific code is handled by the implementation of this interface.
+ Use the `IRI_API_ADAPTER` environment variable (defaults to `app.demo_adapter.FacilityAdapter`)
+ to install your facility adapter before the API starts.
+ """
+
+ @abstractmethod
+ async def get_facility(self: "FacilityAdapter", modified_since: str | None = None) -> facility_models.Facility | None:
+ pass
+
+ @abstractmethod
+ async def list_sites(
+ self: "FacilityAdapter", modified_since: str | None = None, name: str | None = None, offset: int | None = None, limit: int | None = None, short_name: str | None = None
+ ) -> list[facility_models.Site]:
+ pass
+
+ @abstractmethod
+ async def get_site(
+ self: "FacilityAdapter",
+ site_id: str,
+ modified_since: str | None = None,
+ ) -> facility_models.Site | None:
+ pass
diff --git a/app/routers/facility/models.py b/app/routers/facility/models.py
new file mode 100644
index 00000000..a2527abd
--- /dev/null
+++ b/app/routers/facility/models.py
@@ -0,0 +1,56 @@
+"""Facility-related models."""
+from pydantic import Field, HttpUrl, computed_field
+
+from ...request_context import get_url_prefix
+from ...types.base import NamedObject
+
+
+class Site(NamedObject):
+ """A physical site that hosts resources and is part of a facility."""
+ def _self_path(self) -> str:
+ return f"/facility/sites/{self.id}"
+
+ short_name: str|None = Field(default=None, description="Common or short name of the Site.", example="NERSC")
+ operating_organization: str|None = Field(..., description="Organization operating the Site.", example="Lawrence Berkeley National Laboratory")
+ country_name: str|None = Field(default=None, description="Country name of the Location.", example="United States")
+ locality_name: str|None = Field(default=None, description="City or locality name of the Location.", example="Berkeley")
+ state_or_province_name: str|None = Field(default=None, description="State or province name of the Location.", example="California")
+ street_address: str|None = Field(default=None, description="Street address of the Location.", example="1 Cyclotron Rd")
+ unlocode: str|None = Field(default=None, description="United Nations trade and transport location code.", example="USOAK")
+ altitude: float|None = Field(default=None, description="Altitude of the Location.", example=52.0)
+ latitude: float|None = Field(default=None, description="Latitude of the Location.", example=37.8762)
+ longitude: float|None = Field(default=None, description="Longitude of the Location.", example=-122.2506)
+ resource_ids: list[str] = Field(default_factory=list, exclude=True)
+
+ @computed_field(description="URIs of Resources hosted at this Site.")
+ @property
+ def resource_uris(self) -> list[str]:
+ """Return the list of resource URIs for this site."""
+ return [f"{get_url_prefix()}/status/resources/{resource_id}" for resource_id in self.resource_ids]
+
+ @classmethod
+ def find(cls, items, name=None, description=None, modified_since=None, short_name=None, country_name=None):
+ """Find Locations matching the given criteria."""
+ items = super().find(items, name=name, description=description, modified_since=modified_since)
+ if short_name:
+ items = [item for item in items if item.short_name == short_name]
+ if country_name:
+ items = [item for item in items if item.country_name == country_name]
+ return items
+
+
+class Facility(NamedObject):
+ """ Facility representation, including associated Sites."""
+ def _self_path(self) -> str:
+ return "/facility"
+
+ short_name: str|None = Field(default=None, description="Common or short name of the Facility.", example="ESnet")
+ organization_name: str|None = Field(default=None, description="Operating organization's name.", example="Energy Sciences Network")
+ support_uri: HttpUrl|None = Field(default=None, description="Link to facility support portal.", example="https://support.es.net")
+ site_ids: list[str] = Field(default_factory=list, exclude=True)
+
+ @computed_field(description="URIs of associated Sites.")
+ @property
+ def site_uris(self) -> list[str]:
+ """Return the list of site URIs for this facility."""
+ return [f"{get_url_prefix()}/facility/sites/{site_id}" for site_id in self.site_ids]
diff --git a/app/routers/filesystem/facility_adapter.py b/app/routers/filesystem/facility_adapter.py
index 2c08a3cd..df545a19 100644
--- a/app/routers/filesystem/facility_adapter.py
+++ b/app/routers/filesystem/facility_adapter.py
@@ -1,15 +1,14 @@
import os
from abc import abstractmethod
+from ...types.user import User
from ..status import models as status_models
-from ..account import models as account_models
from . import models as filesystem_models
from ..iri_router import AuthenticatedAdapter
-from typing import Any, Tuple
def to_int(name, default_value):
try:
- return os.environ.get(name) or default_value
+ return int(os.environ.get(name) or default_value)
except:
return default_value
@@ -26,193 +25,87 @@ class FacilityAdapter(AuthenticatedAdapter):
@abstractmethod
async def chmod(
- self : "FacilityAdapter",
- resource: status_models.Resource,
- user: account_models.User,
- request_model: filesystem_models.PutFileChmodRequest
+ self: "FacilityAdapter", resource: status_models.Resource, user: User, request_model: filesystem_models.PutFileChmodRequest
) -> filesystem_models.PutFileChmodResponse:
pass
-
@abstractmethod
async def chown(
- self : "FacilityAdapter",
- resource: status_models.Resource,
- user: account_models.User,
- request_model: filesystem_models.PutFileChownRequest
+ self: "FacilityAdapter", resource: status_models.Resource, user: User, request_model: filesystem_models.PutFileChownRequest
) -> filesystem_models.PutFileChownResponse:
pass
-
@abstractmethod
async def ls(
- self : "FacilityAdapter",
- resource: status_models.Resource,
- user: account_models.User,
- path: str,
- show_hidden: bool,
- numeric_uid: bool,
- recursive: bool,
- dereference: bool,
+ self: "FacilityAdapter", resource: status_models.Resource, user: User, path: str, show_hidden: bool, numeric_uid: bool, recursive: bool, dereference: bool
) -> filesystem_models.GetDirectoryLsResponse:
pass
-
@abstractmethod
- async def head(
- self : "FacilityAdapter",
- resource: status_models.Resource,
- user: account_models.User,
- path: str,
- file_bytes: int,
- lines: int,
- skip_trailing: bool,
- ) -> Tuple[Any, int]:
+ async def head(self: "FacilityAdapter", resource: status_models.Resource, user: User, path: str, file_bytes: int, lines: int, skip_trailing: bool) -> filesystem_models.GetFileHeadResponse:
pass
-
@abstractmethod
- async def tail(
- self : "FacilityAdapter",
- resource: status_models.Resource,
- user: account_models.User,
- path: str,
- file_bytes: int | None,
- lines: int | None,
- skip_trailing: bool,
- ) -> Tuple[Any, int]:
+ async def tail(self: "FacilityAdapter", resource: status_models.Resource, user: User, path: str, file_bytes: int | None, lines: int | None, skip_heading: bool) -> filesystem_models.GetFileTailResponse:
pass
-
@abstractmethod
- async def view(
- self : "FacilityAdapter",
- resource: status_models.Resource,
- user: account_models.User,
- path: str,
- size: int,
- offset: int,
- ) -> filesystem_models.GetViewFileResponse:
+ async def view(self: "FacilityAdapter", resource: status_models.Resource, user: User, path: str, size: int, offset: int) -> filesystem_models.GetViewFileResponse:
pass
-
@abstractmethod
- async def checksum(
- self : "FacilityAdapter",
- resource: status_models.Resource,
- user: account_models.User,
- path: str,
- ) -> filesystem_models.GetFileChecksumResponse:
+ async def checksum(self: "FacilityAdapter", resource: status_models.Resource, user: User, path: str) -> filesystem_models.GetFileChecksumResponse:
pass
-
@abstractmethod
- async def file(
- self : "FacilityAdapter",
- resource: status_models.Resource,
- user: account_models.User,
- path: str,
- ) -> filesystem_models.GetFileTypeResponse:
+ async def file(self: "FacilityAdapter", resource: status_models.Resource, user: User, path: str) -> filesystem_models.GetFileTypeResponse:
pass
-
@abstractmethod
- async def stat(
- self : "FacilityAdapter",
- resource: status_models.Resource,
- user: account_models.User,
- path: str,
- dereference: bool,
- ) -> filesystem_models.GetFileStatResponse:
+ async def stat(self: "FacilityAdapter", resource: status_models.Resource, user: User, path: str, dereference: bool) -> filesystem_models.GetFileStatResponse:
pass
-
@abstractmethod
- async def rm(
- self : "FacilityAdapter",
- resource: status_models.Resource,
- user: account_models.User,
- path: str,
- ):
+ async def rm(self: "FacilityAdapter", resource: status_models.Resource, user: User, path: str) -> filesystem_models.RemoveResponse:
pass
-
@abstractmethod
- async def mkdir(
- self : "FacilityAdapter",
- resource: status_models.Resource,
- user: account_models.User,
- request_model: filesystem_models.PostMakeDirRequest,
- ) -> filesystem_models.PostMkdirResponse:
+ async def mkdir(self: "FacilityAdapter", resource: status_models.Resource, user: User, request_model: filesystem_models.PostMakeDirRequest) -> filesystem_models.PostMkdirResponse:
pass
-
@abstractmethod
async def symlink(
- self : "FacilityAdapter",
+ self: "FacilityAdapter",
resource: status_models.Resource,
- user: account_models.User,
+ user: User,
request_model: filesystem_models.PostFileSymlinkRequest,
) -> filesystem_models.PostFileSymlinkResponse:
pass
-
@abstractmethod
- async def download(
- self : "FacilityAdapter",
- resource: status_models.Resource,
- user: account_models.User,
- path: str,
- ) -> Any:
+ async def download(self: "FacilityAdapter", resource: status_models.Resource, user: User, path: str) -> filesystem_models.GetFileDownloadResponse:
pass
-
@abstractmethod
- async def upload(
- self : "FacilityAdapter",
- resource: status_models.Resource,
- user: account_models.User,
- path: str,
- content: str,
- ) -> None:
+ async def upload(self: "FacilityAdapter", resource: status_models.Resource, user: User, path: str, content: str) -> filesystem_models.PutFileUploadResponse:
pass
-
@abstractmethod
async def compress(
- self : "FacilityAdapter",
- resource: status_models.Resource,
- user: account_models.User,
- request_model: filesystem_models.PostCompressRequest,
+ self: "FacilityAdapter", resource: status_models.Resource, user: User, request_model: filesystem_models.PostCompressRequest
) -> filesystem_models.PostCompressResponse:
pass
-
@abstractmethod
async def extract(
- self : "FacilityAdapter",
- resource: status_models.Resource,
- user: account_models.User,
- request_model: filesystem_models.PostExtractRequest,
+ self: "FacilityAdapter", resource: status_models.Resource, user: User, request_model: filesystem_models.PostExtractRequest
) -> filesystem_models.PostExtractResponse:
pass
-
@abstractmethod
- async def mv(
- self : "FacilityAdapter",
- resource: status_models.Resource,
- user: account_models.User,
- request_model: filesystem_models.PostMoveRequest,
- ) -> filesystem_models.PostMoveResponse:
+ async def mv(self: "FacilityAdapter", resource: status_models.Resource, user: User, request_model: filesystem_models.PostMoveRequest) -> filesystem_models.PostMoveResponse:
pass
-
@abstractmethod
- async def cp(
- self : "FacilityAdapter",
- resource: status_models.Resource,
- user: account_models.User,
- request_model: filesystem_models.PostCopyRequest,
- ) -> filesystem_models.PostCopyResponse:
+ async def cp(self: "FacilityAdapter", resource: status_models.Resource, user: User, request_model: filesystem_models.PostCopyRequest) -> filesystem_models.PostCopyResponse:
pass
diff --git a/app/routers/filesystem/filesystem.py b/app/routers/filesystem/filesystem.py
index a111484a..cd6448a3 100644
--- a/app/routers/filesystem/filesystem.py
+++ b/app/routers/filesystem/filesystem.py
@@ -6,21 +6,14 @@
# SPDX-License-Identifier: BSD-3-Clause
import base64
from typing import Annotated
-from fastapi import (
- Depends,
- HTTPException,
- status,
- Query,
- Request,
- File,
- UploadFile
-)
+from fastapi import Depends, HTTPException, status, Query, Request, File, UploadFile
+from ...types.user import User
from .. import iri_router
from ..error_handlers import DEFAULT_RESPONSES
+from ..iri_meta import iri_meta_dict
from ..status.status import router as status_router, models as status_models
-from ..account.account import models as account_models
from ..task import facility_adapter as task_facility_adapter, models as task_models
-from .import models, facility_adapter
+from . import models, facility_adapter
router = iri_router.IriRouter(
@@ -30,223 +23,218 @@
tags=["filesystem"],
)
-async def _user_resource(
- resource_id: str,
- request: Request,
- ) -> tuple[account_models.User, status_models.Resource]:
- user = await router.adapter.get_user(request.state.current_user_id, request.state.api_key, iri_router.get_client_ip(request))
- if not user:
- raise HTTPException(status_code=404, detail="User not found")
+async def _user_resource(
+ resource_id: str,
+ user: User,
+) -> status_models.Resource:
# look up the resource (todo: maybe ensure it's available)
resource = await status_router.adapter.get_resource(resource_id)
if not resource:
raise HTTPException(status_code=404, detail="Resource not found")
- return (user, resource)
+ return resource
@router.put(
"/chmod/{resource_id:str}",
- dependencies=[Depends(router.current_user)],
description="Change the permission mode of a file(`chmod`)",
status_code=status.HTTP_200_OK,
- response_model=str,
+ response_model=task_models.TaskSubmitResponse,
response_description="File permissions changed successfully",
responses=DEFAULT_RESPONSES,
operation_id="chmod",
+ openapi_extra=iri_meta_dict("production", "required")
)
async def put_chmod(
resource_id: str,
request_model: models.PutFileChmodRequest,
- request : Request,
-) -> str:
- user, resource = await _user_resource(resource_id, request)
+ request: Request,
+ user: User = Depends(router.current_user),
+) -> task_models.TaskSubmitResponse:
+ resource = await _user_resource(resource_id, user)
return await router.task_adapter.put_task(
- user,
- resource,
- task_models.TaskCommand(
+ user=user,
+ resource=resource,
+ task=task_models.TaskCommand(
router=router.get_router_name(),
command="chmod",
args={
"request_model": request_model,
- }
- )
+ },
+ ),
)
@router.put(
"/chown/{resource_id:str}",
- dependencies=[Depends(router.current_user)],
description="Change the ownership of a given file (`chown`)",
status_code=status.HTTP_200_OK,
- response_model=str,
+ response_model=task_models.TaskSubmitResponse,
response_description="File ownership changed successfully",
responses=DEFAULT_RESPONSES,
operation_id="chown",
+ openapi_extra=iri_meta_dict("production", "required")
)
async def put_chown(
resource_id: str,
request_model: models.PutFileChownRequest,
- request : Request,
-) -> str:
- user, resource = await _user_resource(resource_id, request)
+ request: Request,
+ user: User = Depends(router.current_user),
+) -> task_models.TaskSubmitResponse:
+ resource = await _user_resource(resource_id, user)
return await router.task_adapter.put_task(
- user,
- resource,
- task_models.TaskCommand(
+ user=user,
+ resource=resource,
+ task=task_models.TaskCommand(
router=router.get_router_name(),
command="chown",
args={
"request_model": request_model,
- }
- )
+ },
+ ),
)
-
@router.get(
"/file/{resource_id:str}",
- dependencies=[Depends(router.current_user)],
description="Output the type of a file or directory",
status_code=status.HTTP_200_OK,
- response_model=str,
+ response_model=task_models.TaskSubmitResponse,
response_description="Type returned successfully",
responses=DEFAULT_RESPONSES,
operation_id="file",
+ openapi_extra=iri_meta_dict("production", "required")
)
async def get_file(
resource_id: str,
- request : Request,
+ request: Request,
path: Annotated[str, Query(description="A file or folder path")],
-) -> str:
- user, resource = await _user_resource(resource_id, request)
+ user: User = Depends(router.current_user),
+) -> task_models.TaskSubmitResponse:
+ resource = await _user_resource(resource_id, user)
return await router.task_adapter.put_task(
- user,
- resource,
- task_models.TaskCommand(
+ user=user,
+ resource=resource,
+ task=task_models.TaskCommand(
router=router.get_router_name(),
command="file",
args={
"path": path,
- }
- )
+ },
+ ),
)
@router.get(
"/stat/{resource_id:str}",
- dependencies=[Depends(router.current_user)],
description="Output the `stat` of a file",
status_code=status.HTTP_200_OK,
- response_model=str,
+ response_model=task_models.TaskSubmitResponse,
response_description="Stat returned successfully",
responses=DEFAULT_RESPONSES,
operation_id="stat",
+ openapi_extra=iri_meta_dict("production", "required")
)
async def get_stat(
resource_id: str,
- request : Request,
+ request: Request,
path: Annotated[str, Query(description="A file or folder path")],
dereference: Annotated[bool, Query(description="Follow symbolic links")] = False,
-) -> str:
- user, resource = await _user_resource(resource_id, request)
+ user: User = Depends(router.current_user),
+) -> task_models.TaskSubmitResponse:
+ resource = await _user_resource(resource_id, user)
return await router.task_adapter.put_task(
- user,
- resource,
- task_models.TaskCommand(
+ user=user,
+ resource=resource,
+ task=task_models.TaskCommand(
router=router.get_router_name(),
command="stat",
args={
"path": path,
"dereference": dereference,
- }
- )
+ },
+ ),
)
@router.post(
"/mkdir/{resource_id:str}",
- dependencies=[Depends(router.current_user)],
description="Create directory operation (`mkdir`)",
status_code=status.HTTP_201_CREATED,
- response_model=str,
+ response_model=task_models.TaskSubmitResponse,
response_description="Directory created successfully",
responses=DEFAULT_RESPONSES,
operation_id="mkdir",
+ openapi_extra=iri_meta_dict("production", "required")
)
async def post_mkdir(
resource_id: str,
- request : Request,
+ request: Request,
request_model: models.PostMakeDirRequest,
-) -> str:
- user, resource = await _user_resource(resource_id, request)
+ user: User = Depends(router.current_user),
+) -> task_models.TaskSubmitResponse:
+ resource = await _user_resource(resource_id, user)
return await router.task_adapter.put_task(
- user,
- resource,
- task_models.TaskCommand(
+ user=user,
+ resource=resource,
+ task=task_models.TaskCommand(
router=router.get_router_name(),
command="mkdir",
args={
"request_model": request_model,
- }
- )
+ },
+ ),
)
-
@router.post(
"/symlink/{resource_id:str}",
- dependencies=[Depends(router.current_user)],
description="Create symlink operation (`ln`)",
status_code=status.HTTP_201_CREATED,
- response_model=str,
+ response_model=task_models.TaskSubmitResponse,
response_description="Symlink created successfully",
responses=DEFAULT_RESPONSES,
operation_id="symlink",
+ openapi_extra=iri_meta_dict("production", "required")
)
async def post_symlink(
resource_id: str,
- request : Request,
+ request: Request,
request_model: models.PostFileSymlinkRequest,
-) -> str:
- user, resource = await _user_resource(resource_id, request)
+ user: User = Depends(router.current_user),
+) -> task_models.TaskSubmitResponse:
+ resource = await _user_resource(resource_id, user)
return await router.task_adapter.put_task(
- user,
- resource,
- task_models.TaskCommand(
+ user=user,
+ resource=resource,
+ task=task_models.TaskCommand(
router=router.get_router_name(),
command="symlink",
args={
"request_model": request_model,
- }
- )
+ },
+ ),
)
@router.get(
"/ls/{resource_id:str}",
- dependencies=[Depends(router.current_user)],
description="List the contents of the given directory (`ls`) asynchronously",
status_code=status.HTTP_200_OK,
- response_model=str,
+ response_model=task_models.TaskSubmitResponse,
response_description="Directory listed successfully",
include_in_schema=router.task_adapter is not None,
responses=DEFAULT_RESPONSES,
operation_id="ls",
+ openapi_extra=iri_meta_dict("production", "required")
)
async def get_ls_async(
resource_id: str,
- request : Request,
+ request: Request,
path: Annotated[str, Query(description="The path to list")],
- show_hidden: Annotated[
- bool, Query(alias="showHidden", description="Show hidden files")
- ] = False,
- numeric_uid: Annotated[
- bool, Query(alias="numericUid", description="List numeric user and group IDs")
- ] = False,
- recursive: Annotated[
- bool, Query(alias="recursive", description="Recursively list files and folders")
- ] = False,
+ show_hidden: Annotated[bool, Query(alias="showHidden", description="Show hidden files")] = False,
+ numeric_uid: Annotated[bool, Query(alias="numericUid", description="List numeric user and group IDs")] = False,
+ recursive: Annotated[bool, Query(alias="recursive", description="Recursively list files and folders")] = False,
dereference: Annotated[
bool,
Query(
@@ -254,38 +242,34 @@ async def get_ls_async(
description="Show information for the file the link references.",
),
] = False,
-) -> str:
- user, resource = await _user_resource(resource_id, request)
+ user: User = Depends(router.current_user),
+
+) -> task_models.TaskSubmitResponse:
+ resource = await _user_resource(resource_id, user)
return await router.task_adapter.put_task(
- user,
- resource,
- task_models.TaskCommand(
- router=router.get_router_name(),
+ user=user,
+ resource=resource,
+ task=task_models.TaskCommand(
+ router=router.get_router_name(),
command="ls",
- args={
- "path": path,
- "show_hidden": show_hidden,
- "numeric_uid": numeric_uid,
- "recursive": recursive,
- "dereference": dereference
- }
- )
+ args={"path": path, "show_hidden": show_hidden, "numeric_uid": numeric_uid, "recursive": recursive, "dereference": dereference}
+ ),
)
@router.get(
"/head/{resource_id:str}",
- dependencies=[Depends(router.current_user)],
description="Output the first part of file/s (`head`)",
status_code=status.HTTP_200_OK,
- response_model=str,
+ response_model=task_models.TaskSubmitResponse,
response_description="Head operation finished successfully",
responses=DEFAULT_RESPONSES,
operation_id="head",
+ openapi_extra=iri_meta_dict("production", "required")
)
async def get_head(
resource_id: str,
- request : Request,
+ request: Request,
path: Annotated[str, Query(description="File path")],
# TODO Should we allow bytes and lines to be strings? The head allows the following:
# NUM may have a multiplier suffix: b 512, kB 1000, K 1024, MB
@@ -309,25 +293,19 @@ async def get_head(
bool,
Query(
alias="skipTrailing",
- description=(
- "The output will be the whole file, without the last NUM "
- "bytes/lines of each file. NUM should be specified in the "
- "respective argument through `bytes` or `lines`."
- ),
+ description=("The output will be the whole file, without the last NUM bytes/lines of each file. NUM should be specified in the respective argument through `bytes` or `lines`."),
),
] = False,
-) -> str:
- user, resource = await _user_resource(resource_id, request)
+ user: User = Depends(router.current_user),
+) -> task_models.TaskSubmitResponse:
+ resource = await _user_resource(resource_id, user)
# Enforce that exactly one of `bytes` or `lines` is specified
if (file_bytes is None and lines is None) or (file_bytes is not None and lines is not None):
- raise HTTPException(
- status_code=400,
- detail="Exactly one of `bytes` or `lines` must be specified."
- )
+ raise HTTPException(status_code=400, detail="Exactly one of `bytes` or `lines` must be specified.")
return await router.task_adapter.put_task(
- user,
- resource,
- task_models.TaskCommand(
+ user=user,
+ resource=resource,
+ task=task_models.TaskCommand(
router=router.get_router_name(),
command="head",
args={
@@ -335,93 +313,63 @@ async def get_head(
"file_bytes": file_bytes,
"lines": lines,
"skip_trailing": skip_trailing,
- }
- )
+ },
+ ),
)
@router.get(
"/view/{resource_id:str}",
- dependencies=[Depends(router.current_user)],
description=f"View file content (up to max {facility_adapter.OPS_SIZE_LIMIT} bytes)",
status_code=status.HTTP_200_OK,
- response_model=str,
+ response_model=task_models.TaskSubmitResponse,
response_description="View operation finished successfully",
responses=DEFAULT_RESPONSES,
operation_id="view",
+ openapi_extra=iri_meta_dict("production", "required")
)
async def get_view(
resource_id: str,
- request : Request,
+ request: Request,
path: Annotated[str, Query(description="File path")],
- size: Annotated[
- int,
- Query(
- alias="size",
- description="Value, in bytes, of the size of data to be retrieved from the file.",
- ),
- ] = facility_adapter.OPS_SIZE_LIMIT,
- offset: Annotated[
- int,
- Query(
- alias="offset",
- description="Value in bytes of the offset.",
- ),
- ] = 0,
-) -> str:
- user, resource = await _user_resource(resource_id, request)
-
- if offset < 0:
- raise HTTPException(
- status_code=status.HTTP_400_BAD_REQUEST,
- detail="`offset` value must be an integer value equal or greater than 0",
- )
-
- if size <= 0:
- raise HTTPException(
- status_code=status.HTTP_400_BAD_REQUEST,
- detail="`size` value must be an integer value greater than 0",
- )
-
- if size > facility_adapter.OPS_SIZE_LIMIT:
- raise HTTPException(
- status_code=status.HTTP_400_BAD_REQUEST,
- detail=f"`size` value must be less than {facility_adapter.OPS_SIZE_LIMIT} bytes",
- )
+ size: Annotated[int, Query(description="Value, in bytes, of the size of data to be retrieved from the file.", ge=1, le=facility_adapter.OPS_SIZE_LIMIT)] = facility_adapter.OPS_SIZE_LIMIT,
+ offset: Annotated[int, Query(description="Value in bytes of the offset.", ge=0)] = 0,
+ user: User = Depends(router.current_user),
+) -> task_models.TaskSubmitResponse:
+ resource = await _user_resource(resource_id, user)
return await router.task_adapter.put_task(
- user,
- resource,
- task_models.TaskCommand(
+ user=user,
+ resource=resource,
+ task=task_models.TaskCommand(
router=router.get_router_name(),
command="view",
args={
"path": path,
- "size": size or facility_adapter.OPS_SIZE_LIMIT,
- "offset": offset or 0,
-
- }
- )
+ "size": size,
+ "offset": offset,
+ },
+ ),
)
@router.get(
"/tail/{resource_id:str}",
- dependencies=[Depends(router.current_user)],
description="Output the last part of a file (`tail`)",
status_code=status.HTTP_200_OK,
- response_model=str,
+ response_model=task_models.TaskSubmitResponse,
response_description="`tail` operation finished successfully",
responses=DEFAULT_RESPONSES,
operation_id="tail",
+ openapi_extra=iri_meta_dict("production", "required")
)
async def get_tail(
resource_id: str,
- request : Request,
+ request: Request,
path: Annotated[str, Query(description="File path", min_length=1)],
- file_bytes: Annotated[int, Query(alias="bytes",
- description="The output will be the last NUM bytes of each file.",
- ge=1),
+ file_bytes: Annotated[
+ int,
+ Query(alias="bytes", description="The output will be the last NUM bytes of each file.", ge=1),
] = None,
lines: Annotated[
int,
@@ -434,25 +382,20 @@ async def get_tail(
bool,
Query(
alias="skipHeading",
- description=(
- "The output will be the whole file, without the first NUM "
- "bytes/lines of each file. NUM should be specified in the "
- "respective argument through `bytes` or `lines`."
- ),
+ description=("The output will be the whole file, without the first NUM bytes/lines of each file. NUM should be specified in the respective argument through `bytes` or `lines`."),
),
] = False,
-) -> str:
- user, resource = await _user_resource(resource_id, request)
+ user: User = Depends(router.current_user),
+
+) -> task_models.TaskSubmitResponse:
+ resource = await _user_resource(resource_id, user)
# Enforce that exactly one of `bytes` or `lines` is specified
if (file_bytes is None and lines is None) or (file_bytes is not None and lines is not None):
- raise HTTPException(
- status_code=400,
- detail="Exactly one of `bytes` or `lines` must be specified."
- )
+ raise HTTPException(status_code=400, detail="Exactly one of `bytes` or `lines` must be specified.")
return await router.task_adapter.put_task(
- user,
- resource,
- task_models.TaskCommand(
+ user=user,
+ resource=resource,
+ task=task_models.TaskCommand(
router=router.get_router_name(),
command="tail",
args={
@@ -460,230 +403,237 @@ async def get_tail(
"file_bytes": file_bytes,
"lines": lines,
"skip_heading": skip_heading,
-
- }
- )
+ },
+ ),
)
@router.get(
"/checksum/{resource_id:str}",
- dependencies=[Depends(router.current_user)],
description="Output the checksum of a file (using SHA-256 algotithm)",
status_code=status.HTTP_200_OK,
- response_model=str,
+ response_model=task_models.TaskSubmitResponse,
response_description="Checksum returned successfully",
responses=DEFAULT_RESPONSES,
operation_id="checksum",
+ openapi_extra=iri_meta_dict("production", "required")
)
async def get_checksum(
resource_id: str,
- request : Request,
+ request: Request,
path: Annotated[str, Query(description="Target system")],
-) -> str:
- user, resource = await _user_resource(resource_id, request)
+ user: User = Depends(router.current_user),
+) -> task_models.TaskSubmitResponse:
+ resource = await _user_resource(resource_id, user)
return await router.task_adapter.put_task(
- user,
- resource,
- task_models.TaskCommand(
+ user=user,
+ resource=resource,
+ task=task_models.TaskCommand(
router=router.get_router_name(),
command="checksum",
args={
"path": path,
- }
- )
+ },
+ ),
)
+
@router.delete(
"/rm/{resource_id:str}",
- dependencies=[Depends(router.current_user)],
description="Delete file or directory operation (`rm`)",
response_description="File or directory deleted successfully",
responses=DEFAULT_RESPONSES,
operation_id="rm",
+ openapi_extra=iri_meta_dict("production", "required")
)
async def delete_rm(
resource_id: str,
- request : Request,
+ request: Request,
path: Annotated[str, Query(description="The path to delete")],
-) -> str:
- user, resource = await _user_resource(resource_id, request)
+ user: User = Depends(router.current_user),
+) -> task_models.TaskSubmitResponse:
+ resource = await _user_resource(resource_id, user)
return await router.task_adapter.put_task(
- user,
- resource,
- task_models.TaskCommand(
+ user=user,
+ resource=resource,
+ task=task_models.TaskCommand(
router=router.get_router_name(),
command="rm",
args={
"path": path,
- }
- )
+ },
+ ),
)
@router.post(
"/compress/{resource_id:str}",
- dependencies=[Depends(router.current_user)],
description="Compress files and directories using `tar` command",
status_code=status.HTTP_201_CREATED,
- response_model=str,
+ response_model=task_models.TaskSubmitResponse,
response_description="File and/or directories compressed successfully",
responses=DEFAULT_RESPONSES,
operation_id="compress",
+ openapi_extra=iri_meta_dict("production", "required")
)
async def post_compress(
resource_id: str,
- request : Request,
+ request: Request,
request_model: models.PostCompressRequest,
-) -> str:
- user, resource = await _user_resource(resource_id, request)
+ user: str = Depends(router.current_user),
+) -> task_models.TaskSubmitResponse:
+ resource = await _user_resource(resource_id, user)
return await router.task_adapter.put_task(
- user,
- resource,
- task_models.TaskCommand(
+ user=user,
+ resource=resource,
+ task=task_models.TaskCommand(
router=router.get_router_name(),
command="compress",
args={
"request_model": request_model,
- }
- )
+ },
+ ),
)
@router.post(
"/extract/{resource_id:str}",
- dependencies=[Depends(router.current_user)],
description="Extract `tar` `gzip` archives",
status_code=status.HTTP_201_CREATED,
- response_model=str,
+ response_model=task_models.TaskSubmitResponse,
response_description="File extracted successfully",
responses=DEFAULT_RESPONSES,
operation_id="extract",
+ openapi_extra=iri_meta_dict("production", "required")
)
async def post_extract(
resource_id: str,
- request : Request,
+ request: Request,
request_model: models.PostExtractRequest,
-) -> str:
- user, resource = await _user_resource(resource_id, request)
+ user: User = Depends(router.current_user),
+) -> task_models.TaskSubmitResponse:
+ resource = await _user_resource(resource_id, user)
return await router.task_adapter.put_task(
- user,
- resource,
- task_models.TaskCommand(
+ user=user,
+ resource=resource,
+ task=task_models.TaskCommand(
router=router.get_router_name(),
command="extract",
args={
"request_model": request_model,
- }
- )
+ },
+ ),
)
@router.post(
"/mv/{resource_id:str}",
- dependencies=[Depends(router.current_user)],
description="Create move file or directory operation (`mv`)",
status_code=status.HTTP_201_CREATED,
- response_model=str,
+ response_model=task_models.TaskSubmitResponse,
response_description="Move file or directory operation created successfully",
responses=DEFAULT_RESPONSES,
operation_id="mv",
+ openapi_extra=iri_meta_dict("production", "required")
)
async def move_mv(
resource_id: str,
- request : Request,
+ request: Request,
request_model: models.PostMoveRequest,
-) -> str:
- user, resource = await _user_resource(resource_id, request)
+ user: User = Depends(router.current_user),
+) -> task_models.TaskSubmitResponse:
+ resource = await _user_resource(resource_id, user)
return await router.task_adapter.put_task(
- user,
- resource,
- task_models.TaskCommand(
+ user=user,
+ resource=resource,
+ task=task_models.TaskCommand(
router=router.get_router_name(),
command="mv",
args={
"request_model": request_model,
- }
- )
+ },
+ ),
)
@router.post(
"/cp/{resource_id:str}",
- dependencies=[Depends(router.current_user)],
description="Create copy file or directory operation (`cp`)",
status_code=status.HTTP_201_CREATED,
- response_model=str,
+ response_model=task_models.TaskSubmitResponse,
response_description="Copy file or directory operation created successfully",
responses=DEFAULT_RESPONSES,
operation_id="cp",
+ openapi_extra=iri_meta_dict("production", "required")
)
async def post_cp(
resource_id: str,
- request : Request,
+ request: Request,
request_model: models.PostCopyRequest,
-) -> str:
- user, resource = await _user_resource(resource_id, request)
+ user: User = Depends(router.current_user),
+) -> task_models.TaskSubmitResponse:
+ resource = await _user_resource(resource_id, user)
return await router.task_adapter.put_task(
- user,
- resource,
- task_models.TaskCommand(
+ user=user,
+ resource=resource,
+ task=task_models.TaskCommand(
router=router.get_router_name(),
command="cp",
args={
"request_model": request_model,
- }
- )
+ },
+ ),
)
+
@router.get(
"/download/{resource_id:str}",
- dependencies=[Depends(router.current_user)],
description=f"Download a small file (max {facility_adapter.OPS_SIZE_LIMIT} Bytes)",
status_code=status.HTTP_200_OK,
- response_model=str,
+ response_model=task_models.TaskSubmitResponse,
response_description="File downloaded successfully",
responses=DEFAULT_RESPONSES,
operation_id="download",
+ openapi_extra=iri_meta_dict("production", "required")
)
async def get_download(
resource_id: str,
- request : Request,
+ request: Request,
path: Annotated[str, Query(description="A file to download")],
-) -> str:
- user, resource = await _user_resource(resource_id, request)
+ user: User = Depends(router.current_user),
+) -> task_models.TaskSubmitResponse:
+ resource = await _user_resource(resource_id, user)
return await router.task_adapter.put_task(
- user,
- resource,
- task_models.TaskCommand(
+ user=user,
+ resource=resource,
+ task=task_models.TaskCommand(
router=router.get_router_name(),
command="download",
args={
"path": path,
- }
- )
+ },
+ ),
)
@router.post(
"/upload/{resource_id:str}",
- dependencies=[Depends(router.current_user)],
description=f"Upload a small file (max {facility_adapter.OPS_SIZE_LIMIT} Bytes)",
status_code=status.HTTP_200_OK,
- response_model=str,
+ response_model=task_models.TaskSubmitResponse,
response_description="File uploaded successfully",
responses=DEFAULT_RESPONSES,
operation_id="upload",
+ openapi_extra=iri_meta_dict("production", "required")
)
async def post_upload(
resource_id: str,
- request : Request,
- path: Annotated[
- str, Query(description="Specify path where file should be uploaded.")
- ],
+ request: Request,
+ path: Annotated[str, Query(description="Specify path where file should be uploaded.")],
file: UploadFile = File(description="File to be uploaded as `multipart/form-data`"),
-) -> str:
- user, resource = await _user_resource(resource_id, request)
+ user: User = Depends(router.current_user),
+) -> task_models.TaskSubmitResponse:
+ resource = await _user_resource(resource_id, user)
raw_content = file.file.read()
if len(raw_content) > facility_adapter.OPS_SIZE_LIMIT:
@@ -693,14 +643,14 @@ async def post_upload(
)
return await router.task_adapter.put_task(
- user,
- resource,
- task_models.TaskCommand(
+ user=user,
+ resource=resource,
+ task=task_models.TaskCommand(
router=router.get_router_name(),
command="upload",
args={
"path": path,
- "content": base64.b64encode(raw_content).decode('utf-8'),
- }
- )
+ "content": base64.b64encode(raw_content).decode("utf-8"),
+ },
+ ),
)
diff --git a/app/routers/filesystem/models.py b/app/routers/filesystem/models.py
index b24c908a..fc36ebca 100644
--- a/app/routers/filesystem/models.py
+++ b/app/routers/filesystem/models.py
@@ -1,142 +1,147 @@
+"""Filesystem-related models."""
# Copied from: https://github.com/eth-cscs/firecrest-v2/blob/master/src/firecrest/filesystem/ops/models.py
-#
+#
# Copyright (c) 2025, ETH Zurich. All rights reserved.
#
# Please, refer to the LICENSE file in the root directory.
# SPDX-License-Identifier: BSD-3-Clause
from enum import Enum
-from typing import Optional
-from humps.camel import case
-from pydantic import Field, AliasChoices, ConfigDict, BaseModel
+from pydantic import Field, AliasChoices, BaseModel
class CompressionType(str, Enum):
- none = "none"
- bzip2 = "bzip2"
- gzip = "gzip"
- xz = "xz"
+ """Defines the type of compression to be used for compressing or extracting files."""
+ none = "none"
+ bzip2 = "bzip2"
+ gzip = "gzip"
+ xz = "xz"
class ContentUnit(str, Enum):
+ """Defines the unit of content for file operations."""
lines = "lines"
bytes = "bytes"
-class CamelModel(BaseModel):
- model_config = ConfigDict(
- alias_generator=case,
- arbitrary_types_allowed=True,
- populate_by_name=True,
- validate_assignment=True,
- )
-
-class File(CamelModel):
- name: str
- type: str
- link_target: Optional[str]
- user: str
- group: str
- permissions: str
- last_modified: str
- size: str
+class File(BaseModel):
+ """Represents a file or directory in the filesystem."""
+ name: str = Field(..., description="File name", example="file.txt")
+ type: str = Field(..., description="File type", example="file")
+ link_target: str|None = Field(default=None, description="Target path if the file is a symbolic link", example="/data/file.txt")
+ user: str = Field(..., description="Owner username", example="user")
+ group: str = Field(..., description="Owner group", example="users")
+ permissions: str = Field(..., description="POSIX permission string", example="rwxr-xr-x")
+ last_modified: str = Field(..., description="Last modification timestamp", example="2026-02-21T12:00:00Z")
+ size: str = Field(..., description="File size in bytes as string", example="1024")
-class FileContent(CamelModel):
- content: str
- content_type: ContentUnit
- start_position: int
- end_position: int
+class FileContent(BaseModel):
+ """Represents the content of a file, along with metadata about the content."""
+ content: str = Field(..., description="File content segment", example="Hello world")
+ content_type: ContentUnit = Field(..., description="Unit used for content slicing", example="lines")
+ start_position: int = Field(..., description="Start position of the returned content", example=0)
+ end_position: int = Field(..., description="End position of the returned content", example=10)
-class FileChecksum(CamelModel):
- algorithm: str = "SHA-256"
- checksum: str
+class FileChecksum(BaseModel):
+ """Represents the checksum information of a file."""
+ algorithm: str = Field(default="SHA-256", description="Checksum algorithm", example="SHA-256")
+ checksum: str = Field(..., description="Checksum value", example="3a7bd3e2360a3d...")
-class FileStat(CamelModel):
+class FileStat(BaseModel):
+ """Represents the metadata information of a file."""
# message: str
- mode: int
- ino: int
- dev: int
- nlink: int
- uid: int
- gid: int
- size: int
- atime: int
- ctime: int
- mtime: int
+ mode: int = Field(..., description="File mode", example=33188)
+ ino: int = Field(..., description="Inode number", example=123456)
+ dev: int = Field(..., description="Device ID", example=2049)
+ nlink: int = Field(..., description="Number of hard links", example=1)
+ uid: int = Field(..., description="User ID of owner", example=1000)
+ gid: int = Field(..., description="Group ID of owner", example=1000)
+ size: int = Field(..., description="File size in bytes", example=1024)
+ atime: int = Field(..., description="Last access time (epoch seconds)", example=1708531200)
+ ctime: int = Field(..., description="Last metadata change time (epoch seconds)", example=1708531200)
+ mtime: int = Field(..., description="Last modification time (epoch seconds)", example=1708531200)
# birthtime: int
-class PatchFile(CamelModel):
- message: str
- new_filepath: str
- new_permissions: str
- new_owner: str
+class PatchFile(BaseModel):
+ """Represents the result of a file patch operation."""
+ message: str = Field(..., description="Result message", example="File updated")
+ new_filepath: str = Field(..., description="New file path", example="/home/user/file.new")
+ new_permissions: str = Field(..., description="Updated permissions", example="755")
+ new_owner: str = Field(..., description="Updated owner", example="user")
+
+class PatchFileMetadataRequest(BaseModel):
+ """Represents a request to update file metadata."""
+ new_filename: str|None = Field(default=None, description="New file name", example="file.new")
+ new_permissions: str|None = Field(default=None, description="New permissions", example="755")
+ new_owner: str|None = Field(default=None, description="New owner", example="user")
-class PatchFileMetadataRequest(CamelModel):
- new_filename: Optional[str] = None
- new_permissions: Optional[str] = None
- new_owner: Optional[str] = None
+class GetDirectoryLsResponse(BaseModel):
+ """Represents the response for a directory listing."""
+ output: list[File]|None = Field(default=None, description="Directory listing")
-class GetDirectoryLsResponse(CamelModel):
- output: Optional[list[File]]
+class GetFileHeadResponse(BaseModel):
+ """Represents the response for reading the beginning of a file."""
+ output: FileContent|None = Field(default=None, description="File content from the beginning")
-class GetFileHeadResponse(CamelModel):
- output: Optional[FileContent]
+class GetFileTailResponse(BaseModel):
+ """Represents the response for reading the end of a file."""
+ output: FileContent|None = Field(default=None, description="File content from the end")
-class GetFileTailResponse(CamelModel):
- output: Optional[FileContent]
+class GetFileChecksumResponse(BaseModel):
+ """Represents the response for getting file checksum information."""
+ output: FileChecksum|None = Field(default=None, description="File checksum information")
-class GetFileChecksumResponse(CamelModel):
- output: Optional[FileChecksum]
+class GetFileTypeResponse(BaseModel):
+ """Represents the response for getting the type of a file."""
+ output: str|None = Field(default=None, description="Type of the file", example="directory")
-class GetFileTypeResponse(CamelModel):
- output: Optional[str] = Field(example="directory")
+class GetFileStatResponse(BaseModel):
+ """Represents the response for getting file metadata information."""
+ output: FileStat|None = Field(default=None, description="File stat information")
-class GetFileStatResponse(CamelModel):
- output: Optional[FileStat]
+class GetFileDownloadResponse(BaseModel):
+ """Represents the response for downloading a file."""
+ output: str|None = Field(default=None, description="Download URL or identifier", example="https://example.com/download/file")
-class PatchFileMetadataResponse(CamelModel):
- output: Optional[PatchFile]
+class PatchFileMetadataResponse(BaseModel):
+ """Represents the response for updating file metadata."""
+ output: PatchFile|None = Field(default=None, description="Updated file metadata")
-class FilesystemRequestBase(CamelModel):
- path: Optional[str] = Field(
- validation_alias=AliasChoices("sourcePath", "source_path"),
- example="/home/user/dir"
- )
+
+class FilesystemRequestBase(BaseModel):
+ """Base class for filesystem operation requests."""
+ # Should we allow both: path and source_path? Or just one of them?
+ path: str|None = Field(default=None, validation_alias=AliasChoices("path", "source_path"), description="Source file or directory path", example="/home/user/dir")
class PutFileChmodRequest(FilesystemRequestBase):
- mode: str = Field(..., description="Mode in octal permission format")
- model_config = {
- "json_schema_extra": {
- "examples": [{"path": "/home/user/dir/file.out", "mode": "777"}]
- }
- }
+ """Represents a request to change file permissions."""
+ mode: str = Field(..., description="Mode in octal permission format", example="777")
+ model_config = {"json_schema_extra": {"examples": [{"path": "/home/user/dir/file.out", "mode": "777"}]}}
-class PutFileChmodResponse(CamelModel):
- output: Optional[File]
+class PutFileChmodResponse(BaseModel):
+ """Represents the response for changing file permissions."""
+ output: File|None = Field(default=None, description="Updated file metadata")
class PutFileChownRequest(FilesystemRequestBase):
- owner: Optional[str] = Field(
- default="", description="User name of the new user owner of the file"
- )
- group: Optional[str] = Field(
- default="", description="Group name of the new group owner of the file"
- )
+ """Represents a request to change file ownership."""
+ owner: str = Field(default="", description="User name of the new user owner of the file", example="user")
+ group: str = Field(default="", description="Group name of the new group owner of the file", example="my-group")
model_config = {
"json_schema_extra": {
"examples": [
@@ -150,67 +155,61 @@ class PutFileChownRequest(FilesystemRequestBase):
}
-class PutFileChownResponse(CamelModel):
- output: Optional[File]
+class PutFileChownResponse(BaseModel):
+ """Represents the response for changing file ownership."""
+ output: File|None = Field(default=None, description="Updated file metadata")
+
+
+class PutFileUploadResponse(BaseModel):
+ """Represents the response for uploading a file."""
+ output: str|None = Field(default=None, description="Upload result or identifier")
class PostMakeDirRequest(FilesystemRequestBase):
- parent: Optional[bool] = Field(
- default=False,
- description="If set to `true` creates all its parent directories if they do not already exist",
- )
- model_config = {
- "json_schema_extra": {
- "examples": [{"path": "/home/user/dir/newdir", "parent": "true"}]
- }
- }
+ """Represents a request to create a directory."""
+ parent: bool = Field(default=False, description="If set to `true` creates all its parent directories if they do not already exist", example=True)
+ model_config = {"json_schema_extra": {"examples": [{"path": "/home/user/dir/newdir", "parent": "true"}]}}
class PostFileSymlinkRequest(FilesystemRequestBase):
- link_path: str = Field(..., description="Path to the new symlink")
- model_config = {
- "json_schema_extra": {
- "examples": [{"path": "/home/user/dir", "link_path": "/home/user/newlink"}]
- }
- }
+ """Represents a request to create a symbolic link."""
+ link_path: str = Field(..., description="Path to the new symlink", example="/home/user/newlink")
+ model_config = {"json_schema_extra": {"examples": [{"path": "/home/user/dir", "link_path": "/home/user/newlink"}]}}
-class PostFileSymlinkResponse(CamelModel):
- output: Optional[File]
+class PostFileSymlinkResponse(BaseModel):
+ """Represents the response for creating a symbolic link."""
+ output: File|None = Field(default=None, description="Created symlink metadata")
-class GetViewFileResponse(CamelModel):
- output: Optional[str]
+class GetViewFileResponse(BaseModel):
+ """Represents the response for viewing a file."""
+ output: FileContent|None = Field(default=None, description="File content")
-class PostMkdirResponse(CamelModel):
- output: Optional[File]
+class PostMkdirResponse(BaseModel):
+ """Represents the response for creating a directory."""
+ output: File|None = Field(default=None, description="Created directory metadata")
-class PostCompressResponse(CamelModel):
- output: Optional[File]
+class PostCompressResponse(BaseModel):
+ """Represents the response for compressing a file."""
+ output: File|None = Field(default=None, description="Compressed file metadata")
class PostCompressRequest(FilesystemRequestBase):
- target_path: str = Field(..., description="Path to the compressed file")
- match_pattern: Optional[str] = Field(
- default=None, description="Regex pattern to filter files to compress"
- )
- dereference: Optional[bool] = Field(
- default=False,
- description="If set to `true`, it follows symbolic links and archive the files they point to instead of the links themselves.",
- )
- compression: Optional[CompressionType] = Field(
- default="gzip",
- description="Defines the type of compression to be used. By default gzip is used.",
- )
+ """Represents a request to compress a file."""
+ target_path: str = Field(..., description="Path to the compressed file", example="/home/user/file.tar.gz")
+ match_pattern: str|None = Field(default=None, description="Regex pattern to filter files to compress", example=".*\\.txt$")
+ dereference: bool = Field(default=False, description="If set to `true`, it follows symbolic links and archive the files they point to instead of the links themselves.", example=True)
+ compression: CompressionType = Field(default="gzip", description="Defines the type of compression to be used. By default gzip is used.", example="gzip")
model_config = {
"json_schema_extra": {
"examples": [
{
- "sourcePath": "/home/user/dir",
- "targetPath": "/home/user/file.tar.gz",
- "matchPattern": "*./[ab].*\\.txt",
+ "source_path": "/home/user/dir",
+ "target_path": "/home/user/file.tar.gz",
+ "match_pattern": "*./[ab].*\\.txt",
"dereference": "true",
"compression": "none",
}
@@ -219,24 +218,21 @@ class PostCompressRequest(FilesystemRequestBase):
}
-class PostExtractResponse(CamelModel):
- output: Optional[File]
+class PostExtractResponse(BaseModel):
+ """Represents the response for extracting a compressed file."""
+ output: File|None = Field(default=None, description="Extracted file metadata")
class PostExtractRequest(FilesystemRequestBase):
- target_path: str = Field(
- ..., description="Path to the directory where to extract the compressed file"
- )
- compression: Optional[CompressionType] = Field(
- default="gzip",
- description="Defines the type of compression to be used. By default gzip is used.",
- )
+ """Represents a request to extract a compressed file."""
+ target_path: str = Field(..., description="Path to the directory where to extract the compressed file", example="/home/user/dir")
+ compression: CompressionType = Field(default="gzip", description="Defines the type of compression to be used. By default gzip is used.", example="gzip")
model_config = {
"json_schema_extra": {
"examples": [
{
- "sourcePath": "/home/user/dir/file.tar.gz",
- "targetPath": "/home/user/dir",
+ "source_path": "/home/user/dir/file.tar.gz",
+ "target_path": "/home/user/dir",
"compression": "none",
}
]
@@ -245,20 +241,15 @@ class PostExtractRequest(FilesystemRequestBase):
class PostCopyRequest(FilesystemRequestBase):
- target_path: str = Field(..., description="Target path of the copy operation")
- dereference: Optional[bool] = Field(
- default=False,
- description=(
- "If set to `true`, it follows symbolic links and copies the "
- "files they point to instead of the links themselves."
- ),
- )
+ """Represents a request to copy a file."""
+ target_path: str = Field(..., description="Target path of the copy operation", example="/home/user/dir/file.new")
+ dereference: bool = Field(default=False, description=("If set to `true`, it follows symbolic links and copies the files they point to instead of the links themselves."), example=True)
model_config = {
"json_schema_extra": {
"examples": [
{
- "sourcePath": "/home/user/dir/file.orig",
- "targetPath": "/home/user/dir/file.new",
+ "source_path": "/home/user/dir/file.orig",
+ "target_path": "/home/user/dir/file.new",
"dereference": "true",
}
]
@@ -266,24 +257,31 @@ class PostCopyRequest(FilesystemRequestBase):
}
-class PostCopyResponse(CamelModel):
- output: Optional[File]
+class PostCopyResponse(BaseModel):
+ """Represents the response for copying a file."""
+ output: File|None = Field(default=None, description="Copied file metadata")
class PostMoveRequest(FilesystemRequestBase):
- target_path: str = Field(..., description="Target path of the move operation")
+ """Represents a request to move a file."""
+ target_path: str = Field(..., description="Target path of the move operation", example="/home/user/dir/file.new")
model_config = {
"json_schema_extra": {
"examples": [
{
- "sourcePath": "/home/user/dir/file.orig",
- "targetPath": "/home/user/dir/file.new",
+ "source_path": "/home/user/dir/file.orig",
+ "target_path": "/home/user/dir/file.new",
}
]
}
}
-class PostMoveResponse(CamelModel):
- output: Optional[File]
+class PostMoveResponse(BaseModel):
+ """Represents the response for moving a file."""
+ output: File|None = Field(default=None, description="Moved file metadata")
+
+class RemoveResponse(BaseModel):
+ """Represents the response for removing a file or directory."""
+ output: str|None = Field(default=None, description="Removal result message")
diff --git a/app/routers/iri_meta.py b/app/routers/iri_meta.py
new file mode 100644
index 00000000..bc97b9f3
--- /dev/null
+++ b/app/routers/iri_meta.py
@@ -0,0 +1,38 @@
+#!/usr/bin/env python3
+"""
+Utility for generating the IRI OpenAPI extension metadata.
+It generates:
+{
+ "x-iri": {
+ "maturity": "production",
+ "implementation": {
+ "level": "required",
+ "required_if_capability": "dpu"
+ }
+ }
+}
+"""
+
+
+def iri_meta_dict(
+ maturity: str | None = None,
+ implementation_level: str | None = None,
+ required_if: str | None = None,
+) -> dict:
+ """Generate the IRI OpenAPI extension metadata."""
+
+ out_obj = {}
+
+ if maturity is not None:
+ out_obj["maturity"] = maturity
+
+ if implementation_level is not None:
+ out_obj.setdefault("implementation", {})["level"] = implementation_level
+
+ if required_if is not None:
+ out_obj.setdefault("implementation", {})["required_if_capability"] = required_if
+
+ if not out_obj:
+ return {}
+
+ return {"x-iri": out_obj}
diff --git a/app/routers/iri_router.py b/app/routers/iri_router.py
index dafb9702..8abcc4b5 100644
--- a/app/routers/iri_router.py
+++ b/app/routers/iri_router.py
@@ -2,26 +2,29 @@
import os
import logging
import importlib
-import datetime
+import time
+import globus_sdk
from fastapi import Request, Depends, HTTPException, APIRouter
-from fastapi.security import APIKeyHeader
-from pydantic_core import core_schema
-from .account.models import User
+from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
-bearer_token = APIKeyHeader(name="Authorization")
+from ..types.user import User
+bearer_scheme = HTTPBearer()
-def get_client_ip(request : Request) -> str|None:
- # logging.debug("Request headers=%s" % request.headers)
- # logging.debug("client=%s" % request.client.host)
+GLOBUS_RS_ID = os.environ.get("GLOBUS_RS_ID")
+GLOBUS_RS_SECRET = os.environ.get("GLOBUS_RS_SECRET")
+GLOBUS_RS_SCOPE_SUFFIX = os.environ.get("GLOBUS_RS_SCOPE_SUFFIX")
+
+
+def get_client_ip(request: Request) -> str | None:
forwarded_for = request.headers.get("X-Forwarded-For")
if forwarded_for:
return forwarded_for.split(",")[0].strip()
else:
- ip_addr = request.headers.get('HTTP_X_REAL_IP')
+ ip_addr = request.headers.get("HTTP_X_REAL_IP")
if not ip_addr:
- ip_addr = request.headers.get('x-real-ip')
+ ip_addr = request.headers.get("x-real-ip")
if not ip_addr:
ip_addr = request.client.host
return ip_addr
@@ -41,16 +44,14 @@ def __init__(self, router_adapter=None, task_router_adapter=None, **kwargs):
if task_router_adapter:
self.task_adapter = IriRouter.create_adapter("task", task_router_adapter)
if not self.task_adapter:
- logging.getLogger().info(f"Hiding {router_name} because \"task\" adapter was not found")
+ logging.getLogger().info(f'Hiding {router_name} because "task" adapter was not found')
self.include_in_schema = False
-
def get_router_name(self):
return self.prefix.replace("/", "").strip()
-
@staticmethod
- def _get_adapter_name(router_name: str) -> str|None:
+ def _get_adapter_name(router_name: str) -> str | None:
"""Return the adapter name, or None if it's not configured and IRI_SHOW_MISSING_ROUTES is true"""
# if there is no adapter specified for this router,
# and IRI_SHOW_MISSING_ROUTES is not true,
@@ -62,7 +63,6 @@ def _get_adapter_name(router_name: str) -> str|None:
# find and load the actual implementation
return os.environ.get(env_var, "app.demo_adapter.DemoAdapter")
-
@staticmethod
def create_adapter(router_name, router_adapter):
# Load the facility-specific adapter
@@ -70,7 +70,6 @@ def create_adapter(router_name, router_adapter):
if not adapter_name:
return None
-
parts = adapter_name.rsplit(".", 1)
module = importlib.import_module(parts[0])
AdapterClass = getattr(module, parts[1])
@@ -81,106 +80,108 @@ def create_adapter(router_name, router_adapter):
return AdapterClass()
+ async def get_globus_info(self, api_key: str) -> dict:
+ """Returns the linked identities and the session info objects"""
+ # Introspect the IRI API token using resource server credentials
+ globus_client = globus_sdk.ConfidentialAppAuthClient(GLOBUS_RS_ID, GLOBUS_RS_SECRET)
+ # grab identity_set_detail for linked identities and session_info to see how the user logged in
+ introspect = globus_client.oauth2_token_introspect(api_key, include="identity_set_detail,session_info")
+ logging.getLogger().info("IRI TOKEN INTROSPECTION:")
+ logging.getLogger().info(introspect)
+ if not introspect.get("active"):
+ raise Exception("Inactive token")
+
+ # Check exp (expiration time) claim
+ exp = introspect.get("exp")
+ if exp and time.time() >= exp:
+ raise Exception("Token has expired")
+
+ # Check nbf (not before) claim
+ nbf = introspect.get("nbf")
+ if nbf and time.time() < nbf:
+ raise Exception("Token not yet valid")
+
+ # Check if token has the required IRI scope
+ token_scope = introspect.get("scope", "").split()
+ GLOBUS_SCOPE = f"https://auth.globus.org/scopes/{GLOBUS_RS_ID}/{GLOBUS_RS_SCOPE_SUFFIX}"
+ if GLOBUS_SCOPE not in token_scope:
+ raise Exception(f"Token missing required scope: {GLOBUS_SCOPE}")
+
+ session_info = introspect.get("session_info")
+
+ if not session_info:
+ raise Exception("No recent login was found in the token (missing session_info). "
+ "Please re-authenticate to obtain a valid session.")
+
+ authentications = session_info.get("authentications")
+ if not authentications:
+ raise Exception("No recent login was found in the token (empty session_info.authentications). "
+ "Please re-authenticate to obtain a valid session.")
+
+ return introspect
+
+
async def current_user(
self,
- request : Request,
- api_key: str = Depends(bearer_token),
+ request: Request,
+ credentials: HTTPAuthorizationCredentials = Depends(bearer_scheme),
):
+ token = credentials.credentials
+ ip_address = get_client_ip(request)
user_id = None
+ globus_introspect = None
+ exc_msg = ""
try:
- user_id = await self.adapter.get_current_user(api_key, get_client_ip(request))
+ if GLOBUS_RS_ID and GLOBUS_RS_SECRET and GLOBUS_RS_SCOPE_SUFFIX:
+ try:
+ globus_introspect = await self.get_globus_info(token)
+ user_id = await self.adapter.get_current_user_globus(token, ip_address, globus_introspect)
+ except Exception as globus_exc:
+ logging.getLogger().exception("Globus error:", exc_info=globus_exc)
+ exc_msg = f"Globus authentication failed: {str(globus_exc)}. || "
+ if not user_id:
+ user_id = await self.adapter.get_current_user(token, ip_address)
except Exception as exc:
- logging.getLogger().error(f"Error parsing IRI_API_PARAMS: {exc}")
- raise HTTPException(status_code=401, detail="Invalid or malformed Authorization parameters") from exc
+ logging.getLogger().exception("Facility Specific auth failed: ", exc_info=exc)
+ exc_msg += f"Facility Specific authentication failed: {str(exc)}"
+ raise HTTPException(status_code=401, detail=exc_msg) from exc
if not user_id:
- raise HTTPException(status_code=403, detail="Unauthorized access")
- request.state.current_user_id = user_id
- request.state.api_key = api_key
+ raise HTTPException(status_code=403, detail="Authentication succeeded but no user ID was identified. Contact Facility Admin.")
+ user = await self.adapter.get_user(
+ user_id=user_id,
+ api_key=token,
+ client_ip=ip_address,
+ globus_introspect=globus_introspect,
+ )
+
+ if not user:
+ raise HTTPException(status_code=404, detail="User not found")
+ return user
-class AuthenticatedAdapter(ABC):
+class AuthenticatedAdapter(ABC):
@abstractmethod
- async def get_current_user(
- self : "AuthenticatedAdapter",
- api_key: str,
- client_ip: str|None,
- ) -> str:
+ async def get_current_user(self: "AuthenticatedAdapter", api_key: str, client_ip: str | None) -> str:
"""
- Decode the api_key and return the authenticated user's id.
- This method is not called directly, rather authorized endpoints "depend" on it.
- (https://fastapi.tiangolo.com/tutorial/dependencies/)
+ Decode the api_key and return the authenticated user's id.
+ This method is not called directly, rather authorized endpoints "depend" on it.
+ (https://fastapi.tiangolo.com/tutorial/dependencies/)
"""
pass
-
@abstractmethod
- async def get_user(
- self : "AuthenticatedAdapter",
- user_id: str,
- api_key: str,
- client_ip: str|None,
- ) -> User:
+ async def get_current_user_globus(self: "AuthenticatedAdapter", api_key: str, client_ip: str | None, globus_introspect: dict | None) -> str:
"""
- Retrieve additional user information (name, email, etc.) for the given user_id.
+ Decode the api_key and return the authenticated user's id from information returned by introspecting a globus token.
+ This method is not called directly, rather authorized endpoints "depend" on it.
+ (https://fastapi.tiangolo.com/tutorial/dependencies/)
"""
pass
-
-def forbidExtraQueryParams(*allowedParams: str):
- """Dependency to forbid extra query parameters not in allowedParams."""
-
- async def checker(_req: Request):
- if "*" in allowedParams:
- return # Permit anything
- incoming = set(_req.query_params.keys())
- allowed = set(allowedParams)
- unknown = incoming - allowed
- if unknown:
- raise HTTPException(status_code=422,
- detail=[{"type": "extra_forbidden", "loc": ["query", param], "msg": f"Unexpected query parameter: {param}"} for param in unknown])
- return checker
-
-class StrictDateTime:
- """
- Strict ISO8601 datetime:
- ✔ Accepts datetime objects
- ✔ Accepts ISO8601 strings: 2025-12-06T10:00:00Z, 2025-12-06T10:00:00+00:00
- ✔ Converts 'Z' → UTC
- ✔ Converts naive datetimes → UTC
- ✘ Rejects integers ("0"), null, garbage strings, etc.
- """
-
- @classmethod
- def __get_pydantic_core_schema__(cls, source, handler):
- return core_schema.no_info_plain_validator_function(cls.validate)
-
- @staticmethod
- def validate(value):
- if isinstance(value, datetime.datetime):
- return StrictDateTime._normalize(value)
- if not isinstance(value, str):
- raise ValueError("Invalid datetime value. Expected ISO8601 datetime string.")
- v = value.strip()
- if v.endswith("Z"):
- v = v[:-1] + "+00:00"
- try:
- dt = datetime.datetime.fromisoformat(v)
- except Exception as ex:
- raise ValueError("Invalid datetime format. Expected ISO8601 string.") from ex
-
- return StrictDateTime._normalize(dt)
-
- @staticmethod
- def _normalize(dt: datetime.datetime) -> datetime.datetime:
- if dt.tzinfo is None:
- return dt.replace(tzinfo=datetime.timezone.utc)
- return dt
-
- @classmethod
- def __get_pydantic_json_schema__(cls, schema, handler):
- return {
- "type": "string",
- "format": "date-time",
- "description": "Strict ISO8601 datetime. Only valid ISO8601 datetime strings are accepted."
- }
+ @abstractmethod
+ async def get_user(self: "AuthenticatedAdapter", user_id: str, api_key: str, client_ip: str | None, globus_introspect: dict | None) -> User:
+ """
+ Retrieve additional user information (name, email, etc.) for the given user_id.
+ """
+ pass
diff --git a/app/routers/status/facility_adapter.py b/app/routers/status/facility_adapter.py
index 6753a470..65b87c4c 100644
--- a/app/routers/status/facility_adapter.py
+++ b/app/routers/status/facility_adapter.py
@@ -1,87 +1,76 @@
-from abc import ABC, abstractmethod
import datetime
-from fastapi import Query
+from abc import ABC, abstractmethod
+
+from ...types.models import Capability
from . import models as status_models
class FacilityAdapter(ABC):
"""
Facility-specific code is handled by the implementation of this interface.
- Use the `IRI_API_ADAPTER` environment variable (defaults to `app.demo_adapter.FacilityAdapter`)
+ Use the `IRI_API_ADAPTER` environment variable (defaults to `app.demo_adapter.FacilityAdapter`)
to install your facility adapter before the API starts.
"""
-
@abstractmethod
async def get_resources(
- self : "FacilityAdapter",
- offset : int,
- limit : int,
- name : str | None = None,
- description : str | None = None,
- group : str | None = None,
- modified_since : datetime.datetime | None = None,
- resource_type: status_models.ResourceType = Query(default=None)
- ) -> list[status_models.Resource]:
+ self: "FacilityAdapter",
+ offset: int,
+ limit: int,
+ name: str | None = None,
+ description: str | None = None,
+ group: str | None = None,
+ modified_since: datetime.datetime | None = None,
+ resource_type: status_models.ResourceType|None = None,
+ current_status: status_models.Status|None = None,
+ capability: Capability | None = None,
+ site_id: str | None = None,
+ ) -> list[status_models.Resource]:
pass
-
@abstractmethod
- async def get_resource(
- self : "FacilityAdapter",
- id : str
- ) -> status_models.Resource:
+ async def get_resource(self: "FacilityAdapter", id_: str) -> status_models.Resource:
pass
-
@abstractmethod
async def get_events(
- self : "FacilityAdapter",
- incident_id : str,
- offset : int,
- limit : int,
- resource_id : str | None = None,
- name : str | None = None,
- description : str | None = None,
- status : status_models.Status | None = None,
- from_ : datetime.datetime | None = None,
- to : datetime.datetime | None = None,
- time : datetime.datetime | None = None,
- modified_since : datetime.datetime | None = None,
- ) -> list[status_models.Event]:
+ self: "FacilityAdapter",
+ offset: int,
+ limit: int,
+ incident_id: str | None = None,
+ resource_id: str | None = None,
+ name: str | None = None,
+ description: str | None = None,
+ status: status_models.Status | None = None,
+ from_: datetime.datetime | None = None,
+ to: datetime.datetime | None = None,
+ time_: datetime.datetime | None = None,
+ modified_since: datetime.datetime | None = None,
+ ) -> list[status_models.Event]:
pass
-
@abstractmethod
- async def get_event(
- self : "FacilityAdapter",
- incident_id : str,
- id : str
- ) -> status_models.Event:
+ async def get_event(self: "FacilityAdapter", id_: str) -> status_models.Event:
pass
-
@abstractmethod
async def get_incidents(
- self : "FacilityAdapter",
- offset : int,
- limit : int,
- name : str | None = None,
- description : str | None = None,
- status : status_models.Status | None = None,
- type_ : status_models.IncidentType | None = None,
- from_ : datetime.datetime | None = None,
- to : datetime.datetime | None = None,
- time_ : datetime.datetime | None = None,
- modified_since : datetime.datetime | None = None,
- resource_id : str | None = None,
- ) -> list[status_models.Incident]:
+ self: "FacilityAdapter",
+ offset: int,
+ limit: int,
+ name: str | None = None,
+ description: str | None = None,
+ status: status_models.Status | None = None,
+ type_: status_models.IncidentType | None = None,
+ from_: datetime.datetime | None = None,
+ to: datetime.datetime | None = None,
+ time_: datetime.datetime | None = None,
+ modified_since: datetime.datetime | None = None,
+ resource_id: str | None = None,
+ resolution: status_models.Resolution | None = None,
+ ) -> list[status_models.Incident]:
pass
-
@abstractmethod
- async def get_incident(
- self : "FacilityAdapter",
- id : str
- ) -> status_models.Incident:
+ async def get_incident(self: "FacilityAdapter", id_: str) -> status_models.Incident:
pass
diff --git a/app/routers/status/models.py b/app/routers/status/models.py
index b1f3a8bb..4fda8015 100644
--- a/app/routers/status/models.py
+++ b/app/routers/status/models.py
@@ -1,53 +1,23 @@
+"""Models for the status API."""
import datetime
import enum
-from pydantic import BaseModel, computed_field, Field
-from ... import config
-class Link(BaseModel):
- rel : str
- href : str
+from pydantic import Field, computed_field, field_validator
+
+from ...request_context import get_url_prefix
+from ...types.base import NamedObject
class Status(enum.Enum):
+ """Represents the status of a resource."""
up = "up"
down = "down"
degraded = "degraded"
unknown = "unknown"
-class NamedResource(BaseModel):
- id : str
- name : str
- description : str
- last_modified : datetime.datetime
-
-
- @staticmethod
- def find_by_id(a, id, allow_name: bool|None=False):
- # Find a resource by its id.
- # If allow_name is True, the id parameter can also match the resource's name.
- return next((r for r in a if r.id == id or (allow_name and r.name == id)), None)
-
-
- @staticmethod
- def find(a, name, description, modified_since):
- def normalize(dt: datetime) -> datetime:
- # Convert naive datetimes into UTC-aware versions
- if dt.tzinfo is None:
- return dt.replace(tzinfo=datetime.timezone.utc)
- return dt
- if name:
- a = [aa for aa in a if aa.name == name]
- if description:
- a = [aa for aa in a if description in aa.description]
- if modified_since:
- if modified_since.tzinfo is None:
- modified_since = modified_since.replace(tzinfo=datetime.timezone.utc)
- a = [aa for aa in a if normalize(aa.last_modified) >= modified_since]
- return a
-
-
class ResourceType(enum.Enum):
+ """Represents the type of a resource."""
website = "website"
service = "service"
compute = "compute"
@@ -57,92 +27,111 @@ class ResourceType(enum.Enum):
unknown = "unknown"
-class Resource(NamedResource):
- capability_ids: list[str] = Field(exclude=True)
- group: str | None
- current_status: Status | None = Field("The current status comes from the status of the last event for this resource")
- resource_type: ResourceType
+class Resource(NamedObject):
+ """Represents a resource in the system."""
+ def _self_path(self) -> str:
+ """Return the API path for this resource."""
+ return f"/status/resources/{self.id}"
+ site_id: str = Field(..., description="The site identifier this resource is located at", exclude=True, example="site-1")
+ capability_ids: list[str] = Field(default_factory=list, exclude=True)
+ group: str|None = Field(default=None, description="Logical grouping of the resource", example="frontend")
+ current_status: Status|None = Field(default=None, description="The current status comes from the status of the last event for this resource", example="up")
+ resource_type: ResourceType = Field(..., description="Type of the resource", example="service")
- @computed_field(description="The url of this object")
+ @computed_field(description="URI of the site where this resource is located")
@property
- def self_uri(self) -> str:
- return f"{config.API_URL_ROOT}{config.API_PREFIX}{config.API_URL}/status/resources/{self.id}"
-
+ def site_uri(self) -> str:
+ """Return the site URI for this resource."""
+ return f"{get_url_prefix()}/facility/sites/{self.site_id}"
- @computed_field(description="The list of past events in this incident")
+ @computed_field(description="The list of capabilities in this resource")
@property
def capability_uris(self) -> list[str]:
- return [f"{config.API_URL_ROOT}{config.API_PREFIX}{config.API_URL}/account/capabilities/{e}" for e in self.capability_ids]
-
+ """Return the list of capability URIs for this resource."""
+ return [f"{get_url_prefix()}/account/capabilities/{e}" for e in self.capability_ids]
- @staticmethod
- def find(resources, name, description, group, modified_since, resource_type):
- a = NamedResource.find(resources, name, description, modified_since)
+ @classmethod
+ def find(cls, items, name=None, description=None, modified_since=None, group=None, resource_type=None, current_status=None, capability=None, site_id=None) -> list:
+ items = super().find(items, name=name, description=description, modified_since=modified_since)
if group:
- a = [aa for aa in a if aa.group == group]
+ items = [item for item in items if item.group == group]
if resource_type:
- a = [aa for aa in a if aa.resource_type == resource_type]
- return a
-
-
-class Event(NamedResource):
- occurred_at : datetime.datetime
- status : Status
- resource_id : str = Field(exclude=True)
- incident_id : str | None = Field(exclude=True, default=None)
-
-
- @computed_field(description="The url of this object")
- @property
- def self_uri(self) -> str:
- return f"{config.API_URL_ROOT}{config.API_PREFIX}{config.API_URL}/status/incidents/{self.incident_id}/events/{self.id}"
-
+ if isinstance(resource_type, str):
+ resource_type = ResourceType(resource_type)
+ items = [item for item in items if item.resource_type == resource_type]
+ if current_status:
+ items = [item for item in items if item.current_status == current_status]
+ if capability:
+ items = [item for item in items if any(cap_id in item.capability_ids for cap_id in capability)]
+ if site_id:
+ items = [item for item in items if item.site_id == site_id]
+ return items
+
+
+class Event(NamedObject):
+ """Represents an event that occurred to a resource, which may be part of an incident."""
+ def _self_path(self) -> str:
+ """Return the API path for this event."""
+ return f"/status/events/{self.id}"
+
+ @field_validator("occurred_at", mode="before")
+ @classmethod
+ def _norm_dt_field(cls, v):
+ return cls.normalize_dt(v)
+
+ occurred_at: datetime.datetime = Field(..., description="Timestamp when the event occurred", example="2026-02-21T12:00:00Z")
+ status: Status = Field(..., description="Status of the resource at the time of the event", example="down")
+ resource_id: str = Field(..., exclude=True, description="Identifier of the affected resource", example="res-1")
+ incident_id: str|None = Field(default=None, exclude=True, description="Identifier of the related incident", example="inc-1")
@computed_field(description="The resource belonging to this event")
@property
def resource_uri(self) -> str:
- return f"{config.API_URL_ROOT}{config.API_PREFIX}{config.API_URL}/status/resources/{self.resource_id}"
-
+ """Return the resource URI for this event."""
+ return f"{get_url_prefix()}/status/resources/{self.resource_id}"
@computed_field(description="The event's incident")
@property
- def incident_uri(self) -> str|None:
- return f"{config.API_URL_ROOT}{config.API_PREFIX}{config.API_URL}/status/incidents/{self.incident_id}" if self.incident_id else None
-
-
- @staticmethod
- def find(
- events : list,
- resource_id : str | None = None,
- name : str | None = None,
- description : str | None = None,
- status : Status | None = None,
- from_ : datetime.datetime | None = None,
- to : datetime.datetime | None = None,
- time_ : datetime.datetime | None = None,
- modified_since : datetime.datetime | None = None,
- ) -> list:
- events = NamedResource.find(events, name, description, modified_since)
+ def incident_uri(self) -> str | None:
+ """Return the incident URI for this event."""
+ return f"{get_url_prefix()}/status/incidents/{self.incident_id}" if self.incident_id else None
+
+ @classmethod
+ def find(cls, items, incident_id=None, name=None, description=None, modified_since=None, resource_id=None, status=None, from_=None, to=None, time_=None) -> list:
+ items = super().find(items, name=name, description=description, modified_since=modified_since)
+
+ if incident_id:
+ items = [e for e in items if e.incident_id == incident_id]
if resource_id:
- events = [e for e in events if e.resource_id == resource_id]
+ items = [e for e in items if e.resource_id == resource_id]
if status:
- events = [e for e in events if e.status == status]
+ if isinstance(status, str):
+ status = Status(status)
+ items = [e for e in items if e.status == status]
+
+ from_ = cls.normalize_dt(from_) if from_ else None
+ to = cls.normalize_dt(to) if to else None
+ time_ = cls.normalize_dt(time_) if time_ else None
+
if from_:
- events = [e for e in events if e.occurred_at >= from_]
+ items = [e for e in items if e.occurred_at >= from_]
if to:
- events = [e for e in events if e.occurred_at < to]
+ items = [e for e in items if e.occurred_at < to]
if time_:
- events = [e for e in events if e.occurred_at == time_]
- return events
+ items = [e for e in items if e.occurred_at == time_]
+ return items
class IncidentType(enum.Enum):
+ """Represents the type of an incident."""
planned = "planned"
unplanned = "unplanned"
+ reservation = "reservation"
class Resolution(enum.Enum):
+ """Represents the resolution status of an incident."""
unresolved = "unresolved"
cancelled = "cancelled"
completed = "completed"
@@ -150,56 +139,59 @@ class Resolution(enum.Enum):
pending = "pending"
-class Incident(NamedResource):
- status : Status
- resource_ids : list[str] = Field(exclude=True)
- event_ids : list[str] = Field(exclude=True)
- start : datetime.datetime
- end : datetime.datetime | None
- type : IncidentType
- resolution : Resolution
-
+class Incident(NamedObject):
+ """Represents an incident that may impact one or more resources."""
+ def _self_path(self) -> str:
+ """Return the API path for this incident."""
+ return f"/status/incidents/{self.id}"
- @computed_field(description="The url of this object")
- @property
- def self_uri(self) -> str:
- return f"{config.API_URL_ROOT}{config.API_PREFIX}{config.API_URL}/status/incidents/{self.id}"
+ @field_validator("start", "end", mode="before")
+ @classmethod
+ def _norm_dt_field(cls, v):
+ return cls.normalize_dt(v)
+ status: Status = Field(..., description="Current status of the incident", example="degraded")
+ resource_ids: list[str] = Field(default_factory=list, exclude=True)
+ event_ids: list[str] = Field(default_factory=list, exclude=True)
+ start: datetime.datetime = Field(..., description="Incident start time", example="2026-02-21T12:00:00Z")
+ end: datetime.datetime|None = Field(default=None, description="Incident end time", example="2026-02-21T14:00:00Z")
+ type: IncidentType = Field(..., description="Type of incident", example="planned")
+ resolution: Resolution = Field(..., description="Resolution status of the incident", example="pending")
@computed_field(description="The list of past events in this incident")
@property
def event_uris(self) -> list[str]:
- return [f"{config.API_URL_ROOT}{config.API_PREFIX}{config.API_URL}/status/incidents/{self.id}/events/{e}" for e in self.event_ids]
-
+ """Return the list of event URIs for this incident."""
+ return [f"{get_url_prefix()}/status/events/{e}" for e in self.event_ids]
@computed_field(description="The list of resources that may be impacted by this incident")
@property
def resource_uris(self) -> list[str]:
- return [f"{config.API_URL_ROOT}{config.API_PREFIX}{config.API_URL}/status/resources/{r}" for r in self.resource_ids]
-
- def find(
- incidents : list,
- name : str | None = None,
- description : str | None = None,
- status : Status | None = None,
- type_ : IncidentType | None = None,
- from_ : datetime.datetime | None = None,
- to : datetime.datetime | None = None,
- time_ : datetime.datetime | None = None,
- modified_since : datetime.datetime | None = None,
- resource_id : str | None = None,
- ) -> list:
- incidents = NamedResource.find(incidents, name, description, modified_since)
+ """Return the list of resource URIs for this incident."""
+ return [f"{get_url_prefix()}/status/resources/{r}" for r in self.resource_ids]
+
+ @classmethod
+ def find(cls, items, name=None, description=None, modified_since=None, status=None, type_=None, from_=None, to=None, time_=None, resource_id=None, resolution=None) -> list:
+ items = super().find(items, name=name, description=description, modified_since=modified_since)
+
if resource_id:
- incidents = [e for e in incidents if resource_id in e.resource_ids]
+ items = [e for e in items if resource_id in e.resource_ids]
if status:
- incidents = [e for e in incidents if e.status == status]
+ items = [e for e in items if e.status == status]
if type_:
- incidents = [e for e in incidents if e.type == type_]
+ items = [e for e in items if e.type == type_]
+ if resolution:
+ items = [e for e in items if e.resolution == resolution]
+
+ from_ = cls.normalize_dt(from_) if from_ else None
+ to = cls.normalize_dt(to) if to else None
+ time_ = cls.normalize_dt(time_) if time_ else None
+
if from_:
- incidents = [e for e in incidents if e.start >= from_]
+ items = [e for e in items if e.start >= from_]
if to:
- incidents = [e for e in incidents if e.end < to]
+ items = [e for e in items if e.end and e.end < to]
+
if time_:
- incidents = [e for e in incidents if e.start <= time_ and e.end > time_]
- return incidents
+ items = [e for e in items if e.start <= time_ and (e.end is None or e.end > time_)]
+ return items
diff --git a/app/routers/status/status.py b/app/routers/status/status.py
index 7bb0fd0b..ce5958e4 100644
--- a/app/routers/status/status.py
+++ b/app/routers/status/status.py
@@ -1,7 +1,13 @@
-from fastapi import HTTPException, Request, Query, Depends
-from . import models, facility_adapter
+from typing import List
+
+from fastapi import Depends, HTTPException, Query, Request
+
+from ...types.http import forbidExtraQueryParams
+from ...types.scalars import AllocationUnit, StrictDateTime
from .. import iri_router
from ..error_handlers import DEFAULT_RESPONSES
+from ..iri_meta import iri_meta_dict
+from . import facility_adapter, models
router = iri_router.IriRouter(
facility_adapter.FacilityAdapter,
@@ -9,25 +15,32 @@
tags=["status"],
)
+
@router.get(
"/resources",
summary="Get all resources",
description="Get a list of all resources at this facility. You can optionally filter the returned list by specifying attribtes.",
responses=DEFAULT_RESPONSES,
operation_id="getResources",
+ response_model_exclude_none=True,
+ openapi_extra=iri_meta_dict("production", "required")
)
async def get_resources(
- request : Request,
- name : str = Query(default=None, min_length=1),
- description : str = Query(default=None, min_length=1),
- group : str = Query(default=None, min_length=1),
- offset : int = Query(default=0, ge=0),
- limit : int = Query(default=100, le=1000),
- modified_since: iri_router.StrictDateTime = Query(default=None),
+ request: Request,
+ name: str = Query(default=None, min_length=1),
+ description: str = Query(default=None, min_length=1),
+ group: str = Query(default=None, min_length=1),
+ offset: int = Query(default=0, ge=0),
+ limit: int = Query(default=100, ge=0, le=1000),
+ modified_since: StrictDateTime = Query(default=None),
resource_type: models.ResourceType = Query(default=None),
- _forbid = Depends(iri_router.forbidExtraQueryParams("name", "description", "group", "offset", "limit", "modified_since", "resource_type")),
- ) -> list[models.Resource]:
- return await router.adapter.get_resources(offset, limit, name, description, group, modified_since, resource_type)
+ current_status: models.Status = Query(default=None),
+ capability: List[AllocationUnit] = Query(default=None, min_length=1),
+ _forbid=Depends(forbidExtraQueryParams("name", "description", "group", "offset", "limit", "modified_since", "resource_type", "current_status", "capability", multiParams={"capability"})),
+) -> list[models.Resource]:
+ return await router.adapter.get_resources(
+ offset=offset, limit=limit, name=name, description=description, group=group, modified_since=modified_since, resource_type=resource_type, current_status=current_status, capability=capability
+ )
@router.get(
@@ -36,11 +49,12 @@ async def get_resources(
description="Get a specific resource for a given id",
responses=DEFAULT_RESPONSES,
operation_id="getResource",
+ openapi_extra=iri_meta_dict("production", "required")
)
async def get_resource(
- request : Request,
- resource_id : str,
- ) -> models.Resource:
+ request: Request,
+ resource_id: str,
+) -> models.Resource:
item = await router.adapter.get_resource(resource_id)
if not item:
raise HTTPException(status_code=404, detail="Item not found")
@@ -53,24 +67,59 @@ async def get_resource(
description="Get a list of all incidents. Each incident will be returned without its events. You can optionally filter the returned list by specifying attributes.",
responses=DEFAULT_RESPONSES,
operation_id="getIncidents",
+ openapi_extra=iri_meta_dict("production", "required")
)
async def get_incidents(
- request : Request,
- name : str = Query(default=None, min_length=1),
- description : str = Query(default=None, min_length=1),
- status : models.Status = Query(default=None),
- type_: models.IncidentType = Query(alias="type", default=None),
- from_: iri_router.StrictDateTime = Query(alias="from", default=None),
- time_ : iri_router.StrictDateTime = Query(alias="time", default=None),
- to : iri_router.StrictDateTime = Query(default=None),
- modified_since : iri_router.StrictDateTime = Query(default=None),
- resource_id : str = Query(default=None, min_length=1),
- offset : int = Query(default=0, ge=0),
- limit : int = Query(default=100, le=1000),
- _forbid = Depends(iri_router.forbidExtraQueryParams("name", "description", "status", "type", "from", "to", "time", "modified_since", "resource_id", "offset", "limit")),
- ) -> list[models.Incident]:
- return await router.adapter.get_incidents(offset, limit, name, description, status, type_, from_, to, time_, modified_since, resource_id)
-
+ request: Request,
+ name: str | None = Query(default=None, min_length=1),
+ description: str | None = Query(default=None, min_length=1),
+ status: models.Status = Query(default=None),
+ type_: models.IncidentType = Query(alias="type", default=None),
+ from_: StrictDateTime = Query(alias="from", default=None),
+ time_: StrictDateTime = Query(alias="time", default=None),
+ to: StrictDateTime = Query(default=None),
+ modified_since: StrictDateTime = Query(default=None),
+ resource_id: str | None = Query(default=None, min_length=1),
+ offset: int = Query(default=0, ge=0),
+ limit: int = Query(default=100, ge=0, le=1000),
+ resolution: models.Resolution = Query(default=None),
+ _forbid=Depends(
+ forbidExtraQueryParams(
+ "name",
+ "description",
+ "status",
+ "type",
+ "from",
+ "to",
+ "time",
+ "modified_since",
+ "resource_id",
+ "offset",
+ "limit",
+ "resolution",
+ "resource_uris",
+ "event_uris",
+ multiParams={"resource_uris", "event_uris"},
+ )
+ ),
+) -> list[models.Incident]:
+ incidents = await router.adapter.get_incidents(
+ offset=offset,
+ limit=limit,
+ name=name,
+ description=description,
+ status=status,
+ type_=type_,
+ from_=from_,
+ to=to,
+ time_=time_,
+ modified_since=modified_since,
+ resource_id=resource_id,
+ resolution=resolution,
+ )
+ if not incidents:
+ raise HTTPException(status_code=404, detail="No incidents found")
+ return incidents
@router.get(
"/incidents/{incident_id}",
@@ -78,12 +127,9 @@ async def get_incidents(
description="Get a specific incident for a given id. The incident's events will also be included. You can optionally filter the returned list by specifying attributes.",
responses=DEFAULT_RESPONSES,
operation_id="getIncident",
-
+ openapi_extra=iri_meta_dict("production", "required")
)
-async def get_incident(
- request : Request,
- incident_id : str
- ) -> models.Incident:
+async def get_incident(request: Request, incident_id: str) -> models.Incident:
item = await router.adapter.get_incident(incident_id)
if not item:
raise HTTPException(status_code=404, detail="Item not found")
@@ -91,43 +137,46 @@ async def get_incident(
@router.get(
- "/incidents/{incident_id}/events",
- summary="Get all events for an incident",
- description="Get a list of all events in this incident. You can optionally filter the returned list by specifying attribtes.",
+ "/events",
+ summary="Get all events",
+ description="Get a list of all events. You can optionally filter the returned list by specifying attribtes.",
responses=DEFAULT_RESPONSES,
operation_id="getEventsByIncident",
+ openapi_extra=iri_meta_dict("production", "required")
)
async def get_events(
- request : Request,
- incident_id : str,
- resource_id : str = Query(default=None, min_length=1),
- name : str = Query(default=None, min_length=1),
- description : str = Query(default=None, min_length=1),
- status : models.Status = Query(default=None),
- from_: iri_router.StrictDateTime = Query(alias="from", default=None),
- time_ : iri_router.StrictDateTime = Query(alias="time", default=None),
- to : iri_router.StrictDateTime = Query(default=None),
- modified_since : iri_router.StrictDateTime = Query(default=None),
- offset : int = Query(default=0, ge=0),
- limit : int = Query(default=100, le=1000),
- _forbid = Depends(iri_router.forbidExtraQueryParams("resource_id", "name", "description", "status", "from", "to", "time", "modified_since", "offset", "limit")),
- ) -> list[models.Event]:
- return await router.adapter.get_events(incident_id, offset, limit, resource_id, name, description, status, from_, to, time_, modified_since)
+ request: Request,
+ incident_id: str | None = Query(default=None, min_length=1),
+ resource_id: str | None = Query(default=None, min_length=1),
+ name: str | None = Query(default=None, min_length=1),
+ description: str | None = Query(default=None, min_length=1),
+ status: models.Status = Query(default=None),
+ from_: StrictDateTime = Query(alias="from", default=None),
+ time_: StrictDateTime = Query(alias="time", default=None),
+ to: StrictDateTime = Query(default=None),
+ modified_since: StrictDateTime = Query(default=None),
+ offset: int = Query(default=0, ge=0),
+ limit: int = Query(default=100, ge=0, le=1000),
+ _forbid=Depends(forbidExtraQueryParams("incident_id", "resource_id", "name", "description", "status", "from", "to", "time", "modified_since", "offset", "limit")),
+) -> list[models.Event]:
+ events = await router.adapter.get_events(
+ incident_id=incident_id, offset=offset, limit=limit, resource_id=resource_id, name=name, description=description, status=status, from_=from_, to=to, time_=time_, modified_since=modified_since
+ )
+ if not events:
+ raise HTTPException(status_code=404, detail="No events found")
+ return events
@router.get(
- "/incidents/{incident_id}/events/{event_id}",
+ "/events/{event_id}",
summary="Get a specific event",
description="Get a specific event for a given id",
responses=DEFAULT_RESPONSES,
operation_id="getEventByIncident",
+ openapi_extra=iri_meta_dict("production", "required")
)
-async def get_event(
- request : Request,
- incident_id : str,
- event_id : str
- ) -> models.Event:
- item = await router.adapter.get_event(incident_id, event_id)
+async def get_event(request: Request, event_id: str) -> models.Event:
+ item = await router.adapter.get_event(event_id)
if not item:
raise HTTPException(status_code=404, detail="Item not found")
return item
diff --git a/app/routers/task/facility_adapter.py b/app/routers/task/facility_adapter.py
index 6659d154..bec55bca 100644
--- a/app/routers/task/facility_adapter.py
+++ b/app/routers/task/facility_adapter.py
@@ -1,11 +1,14 @@
+import traceback
from abc import abstractmethod
-from typing import Any
+from ...types.user import User
from . import models as task_models
-from ..account import models as account_models
from ..status import models as status_models
from ..filesystem import models as filesystem_models, facility_adapter as filesystem_adapter
from ..iri_router import AuthenticatedAdapter, IriRouter
+from ...apilogger import get_stream_logger
+
+logger = get_stream_logger(__name__)
class FacilityAdapter(AuthenticatedAdapter):
"""
@@ -14,110 +17,95 @@ class FacilityAdapter(AuthenticatedAdapter):
to install your facility adapter before the API starts.
"""
-
@abstractmethod
- async def get_task(
- self : "FacilityAdapter",
- user: account_models.User,
- task_id: str,
- ) -> task_models.Task|None:
+ async def get_task(self: "FacilityAdapter", user: User, task_id: str) -> task_models.Task | None:
pass
-
@abstractmethod
- async def get_tasks(
- self : "FacilityAdapter",
- user: account_models.User,
- ) -> list[task_models.Task]:
+ async def get_tasks(self: "FacilityAdapter", user: User) -> list[task_models.Task]:
pass
-
@abstractmethod
- async def put_task(
- self: "FacilityAdapter",
- user: account_models.User,
- resource: status_models.Resource|None,
- command: task_models.TaskCommand
- ) -> str:
+ async def put_task(self: "FacilityAdapter", user: User, resource: status_models.Resource | None, task: task_models.TaskCommand) -> task_models.TaskSubmitResponse:
pass
+ @abstractmethod
+ async def delete_task(self: "FacilityAdapter", user: User, task_id: str) -> None:
+ pass
@staticmethod
- async def on_task(
- resource: status_models.Resource,
- user: account_models.User,
- cmd: task_models.TaskCommand,
- ) -> tuple[str, task_models.TaskStatus]:
+ async def on_task(resource: status_models.Resource, user: User, task: task_models.TaskCommand) -> tuple[dict, task_models.TaskStatus]:
# Handle a task from the facility message queue.
# Returns: (result, status)
+ def _extractNull(ind):
+ if hasattr(ind, "model_dump"):
+ data = ind.model_dump()
+ else:
+ data = ind
+ return {k: v for k, v in data.items() if v is not None}
try:
r = None
- if cmd.router == "filesystem":
- fs_adapter = IriRouter.create_adapter(cmd.router, filesystem_adapter.FacilityAdapter)
- if cmd.command == "chmod":
- request_model = filesystem_models.PutFileChmodRequest.model_validate(cmd.args["request_model"])
- o = await fs_adapter.chmod(resource, user, request_model)
- r = o.model_dump_json()
- elif cmd.command == "chown":
- request_model = filesystem_models.PutFileChownRequest.model_validate(cmd.args["request_model"])
- o = await fs_adapter.chown(resource, user, request_model)
- r = o.model_dump_json()
- elif cmd.command == "file":
- o = await fs_adapter.file(resource, user, **cmd.args)
- r = o.model_dump_json()
- elif cmd.command == "stat":
- o = await fs_adapter.stat(resource, user, **cmd.args)
- r = o.model_dump_json()
- elif cmd.command == "mkdir":
- request_model = filesystem_models.PostMakeDirRequest.model_validate(cmd.args["request_model"])
- o = await fs_adapter.mkdir(resource, user, request_model)
- r = o.model_dump_json()
- elif cmd.command == "symlink":
- request_model = filesystem_models.PostFileSymlinkRequest.model_validate(cmd.args["request_model"])
- o = await fs_adapter.symlink(resource, user, request_model)
- r = o.model_dump_json()
- elif cmd.command == "ls":
- o = await fs_adapter.ls(resource, user, **cmd.args)
- r = o.model_dump_json()
- elif cmd.command == "head":
- o = await fs_adapter.head(resource, user, **cmd.args)
- r = o.model_dump_json()
- elif cmd.command == "view":
- o = await fs_adapter.view(resource, user, **cmd.args)
- r = o.model_dump_json()
- elif cmd.command == "tail":
- o = await fs_adapter.tail(resource, user, **cmd.args)
- r = o.model_dump_json()
- elif cmd.command == "checksum":
- o = await fs_adapter.checksum(resource, user, **cmd.args)
- r = o.model_dump_json()
- elif cmd.command == "rm":
- o = await fs_adapter.rm(resource, user, **cmd.args)
- r = o.model_dump_json()
- elif cmd.command == "compress":
- request_model = filesystem_models.PostCompressRequest.model_validate(cmd.args["request_model"])
- o = await fs_adapter.compress(resource, user, request_model)
- r = o.model_dump_json()
- elif cmd.command == "extract":
- request_model = filesystem_models.PostExtractRequest.model_validate(cmd.args["request_model"])
- o = await fs_adapter.extract(resource, user, request_model)
- r = o.model_dump_json()
- elif cmd.command == "mv":
- request_model = filesystem_models.PostMoveRequest.model_validate(cmd.args["request_model"])
- o = await fs_adapter.mv(resource, user, request_model)
- r = o.model_dump_json()
- elif cmd.command == "cp":
- request_model = filesystem_models.PostCopyRequest.model_validate(cmd.args["request_model"])
- o = await fs_adapter.cp(resource, user, request_model)
- r = o.model_dump_json()
- elif cmd.command == "download":
- r = await fs_adapter.download(resource, user, **cmd.args)
- elif cmd.command == "upload":
- o = await fs_adapter.upload(resource, user, **cmd.args)
- r = "File uploaded successfully"
- if r:
+ logger.info(f"Received task: {task.router}:{task.command} with args: {task.args}")
+ if task.router == "filesystem":
+ fs_adapter = IriRouter.create_adapter(task.router, filesystem_adapter.FacilityAdapter)
+ if task.command == "chmod":
+ data = _extractNull(task.args["request_model"])
+ request_model = filesystem_models.PutFileChmodRequest.model_validate(data)
+ r = await fs_adapter.chmod(resource, user, request_model)
+ elif task.command == "chown":
+ data = _extractNull(task.args["request_model"])
+ request_model = filesystem_models.PutFileChownRequest.model_validate(data)
+ r = await fs_adapter.chown(resource, user, request_model)
+ elif task.command == "file":
+ r = await fs_adapter.file(resource, user, **task.args)
+ elif task.command == "stat":
+ r = await fs_adapter.stat(resource, user, **task.args)
+ elif task.command == "mkdir":
+ data = _extractNull(task.args["request_model"])
+ request_model = filesystem_models.PostMakeDirRequest.model_validate(data)
+ r = await fs_adapter.mkdir(resource, user, request_model)
+ elif task.command == "symlink":
+ data = _extractNull(task.args["request_model"])
+ request_model = filesystem_models.PostFileSymlinkRequest.model_validate(data)
+ r = await fs_adapter.symlink(resource, user, request_model)
+ elif task.command == "ls":
+ r = await fs_adapter.ls(resource, user, **task.args)
+ elif task.command == "head":
+ r = await fs_adapter.head(resource, user, **task.args)
+ elif task.command == "view":
+ r = await fs_adapter.view(resource, user, **task.args)
+ elif task.command == "tail":
+ r = await fs_adapter.tail(resource, user, **task.args)
+ elif task.command == "checksum":
+ r = await fs_adapter.checksum(resource, user, **task.args)
+ elif task.command == "rm":
+ r = await fs_adapter.rm(resource, user, **task.args)
+ elif task.command == "compress":
+ data = _extractNull(task.args["request_model"])
+ request_model = filesystem_models.PostCompressRequest.model_validate(data)
+ r = await fs_adapter.compress(resource, user, request_model)
+ elif task.command == "extract":
+ data = _extractNull(task.args["request_model"])
+ request_model = filesystem_models.PostExtractRequest.model_validate(data)
+ r = await fs_adapter.extract(resource, user, request_model)
+ elif task.command == "mv":
+ data = _extractNull(task.args["request_model"])
+ request_model = filesystem_models.PostMoveRequest.model_validate(data)
+ r = await fs_adapter.mv(resource, user, request_model)
+ elif task.command == "cp":
+ data = _extractNull(task.args["request_model"])
+ request_model = filesystem_models.PostCopyRequest.model_validate(data)
+ r = await fs_adapter.cp(resource, user, request_model)
+ elif task.command == "download":
+ r = await fs_adapter.download(resource, user, **task.args)
+ elif task.command == "upload":
+ r = await fs_adapter.upload(resource, user, **task.args)
+ if r is not None:
return (r, task_models.TaskStatus.completed)
else:
- return (f"Task was cancelled due to unknown router/command: {cmd.router}:{cmd.command}", task_models.TaskStatus.failed)
+ return ({"output": f"Task was cancelled due to unknown router/command: {task.router}:{task.command}"}, task_models.TaskStatus.failed)
except Exception as exc:
- return (f"Error: {exc}", task_models.TaskStatus.failed)
+ traceback_str = traceback.format_exc()
+ logger.warning(f"Error handling task {task.router}:{task.command} with args: {task.args}\nError: {exc}")
+ logger.debug(f"Traceback:\n{traceback_str}")
+ return ({"output": f"Error: {exc}"}, task_models.TaskStatus.failed)
diff --git a/app/routers/task/models.py b/app/routers/task/models.py
index da3c5ccc..ee85b979 100644
--- a/app/routers/task/models.py
+++ b/app/routers/task/models.py
@@ -1,23 +1,40 @@
-from pydantic import BaseModel
+""" Task models for the IRI Facility API """
import enum
+from pydantic import BaseModel, Field, computed_field
+
+from ...request_context import get_url_prefix
+
+
+class TaskSubmitResponse(BaseModel):
+ """Response model for submitting a task"""
+ task_id: str = Field(..., description="Identifier of the submitted task", example="task-123")
+
+ @computed_field(description="The list of past events in this incident")
+ @property
+ def task_uri(self) -> str:
+ """Return the URI for this task."""
+ return f"{get_url_prefix()}/task/{self.task_id}"
class TaskStatus(str, enum.Enum):
- pending = "pending"
- active = "active"
- completed = "completed"
- failed = "failed"
- canceled = "canceled"
+ """Represents the status of a task."""
+ pending = "pending"
+ active = "active"
+ completed = "completed"
+ failed = "failed"
+ canceled = "canceled"
class TaskCommand(BaseModel):
- router: str
- command: str
- args: dict
+ """Represents a command to be executed as part of a task."""
+ router: str = Field(..., description="Router name the task comes from", example="filesystem")
+ command: str = Field(..., description="Command to execute", example="chmod")
+ args: dict = Field(..., description="Command arguments as key-value pairs", example={"path": "/home/user/file", "mode": "755"})
class Task(BaseModel):
- id: str
- status: TaskStatus=TaskStatus.pending
- result: str|None=None
- command: TaskCommand|None=None
+ """Represents a task in the system."""
+ id: str = Field(..., description="Unique identifier of the task", example="task-123")
+ status: TaskStatus = Field(default=TaskStatus.pending, description="Current status of the task", example="pending")
+ result: dict | None = Field(default=None, description="Result of the task execution, if available")
+ command: TaskCommand|None = Field(default=None, description="Command associated with this task")
diff --git a/app/routers/task/task.py b/app/routers/task/task.py
index 094cdd0f..b3a81166 100644
--- a/app/routers/task/task.py
+++ b/app/routers/task/task.py
@@ -1,7 +1,9 @@
from fastapi import Request, HTTPException, Depends
+from ...types.user import User
from .. import iri_router
from ..error_handlers import DEFAULT_RESPONSES
-from .import models, facility_adapter
+from ..iri_meta import iri_meta_dict
+from . import models, facility_adapter
router = iri_router.IriRouter(
facility_adapter.FacilityAdapter,
@@ -12,37 +14,49 @@
@router.get(
"/{task_id:str}",
- dependencies=[Depends(router.current_user)],
response_model_exclude_unset=True,
responses=DEFAULT_RESPONSES,
operation_id="getTask",
+ openapi_extra=iri_meta_dict("production", "required")
)
async def get_task(
- request : Request,
- task_id : str,
- ) -> models.Task:
+ request: Request,
+ task_id: str,
+ user: User = Depends(router.current_user)
+) -> models.Task:
"""Get a task"""
- user = await router.adapter.get_user(request.state.current_user_id, request.state.api_key, iri_router.get_client_ip(request))
- if not user:
- raise HTTPException(status_code=404, detail="User not found")
- task = await router.adapter.get_task(user, task_id)
+ task = await router.adapter.get_task(user=user, task_id=task_id)
if not task:
raise HTTPException(status_code=404, detail=f"Task {task_id} not found")
return task
-@router.get(
- "",
+@router.get("",
+ dependencies=[Depends(router.current_user)],
+ response_model_exclude_unset=True, responses=DEFAULT_RESPONSES,
+ operation_id="getTasks",
+ openapi_extra=iri_meta_dict("production", "required"))
+@router.get("/", responses=DEFAULT_RESPONSES, operation_id="getTasksWithSlash", include_in_schema=False)
+
+async def get_tasks(
+ request: Request,
+ user: User = Depends(router.current_user)
+) -> list[models.Task]:
+ """Get all tasks"""
+ return await router.adapter.get_tasks(user=user)
+
+@router.delete(
+ "/{task_id:str}",
dependencies=[Depends(router.current_user)],
- response_model_exclude_unset=True,
responses=DEFAULT_RESPONSES,
- operation_id="getTasks",
+ operation_id="deleteTask",
+ openapi_extra=iri_meta_dict("production", "required")
)
-async def get_tasks(
- request : Request,
- ) -> list[models.Task]:
- """Get all tasks"""
- user = await router.adapter.get_user(request.state.current_user_id, request.state.api_key, iri_router.get_client_ip(request))
- if not user:
- raise HTTPException(status_code=404, detail="User not found")
- return await router.adapter.get_tasks(user)
+async def delete_task(
+ request: Request,
+ task_id: str,
+ user: User = Depends(router.current_user)
+) -> str:
+ """Delete a task"""
+ await router.adapter.delete_task(user=user, task_id=task_id)
+ return f"Task {task_id} deleted successfully"
\ No newline at end of file
diff --git a/app/s3df/account_adapter.py b/app/s3df/account_adapter.py
index 106e2d8a..34a96b9d 100644
--- a/app/s3df/account_adapter.py
+++ b/app/s3df/account_adapter.py
@@ -18,6 +18,9 @@
from fastapi import HTTPException
+from app.types.user import User
+from app.types.models import Capability
+from app.types.scalars import AllocationUnit
from ..routers.account import models as account_models
from ..routers.account import facility_adapter as account_adapter
from app.s3df.auth.authenticated_adapter import S3DFAuthenticatedAdapter
@@ -38,7 +41,7 @@ def __init__(self, coact_client: CoactClient | None = None):
# AuthenticatedAdapter methods
# -------------------------------------------------------------------------
- async def get_user(self, user_id: str, api_key: str, client_ip: str | None) -> account_models.User:
+ async def get_user(self, user_id: str, api_key: str, client_ip: str | None, globus_introspect: dict | None = None) -> User:
"""
coact.User → IRI.User mapping:
- username → id
@@ -47,7 +50,7 @@ async def get_user(self, user_id: str, api_key: str, client_ip: str | None) -> a
coact_user = await self.coact_client.get_user(user_id)
if not coact_user:
raise HTTPException(status_code=403, detail="User not authorized")
- return account_models.User(
+ return User(
id=coact_user["username"],
name=coact_user.get("fullname", user_id),
api_key=api_key,
@@ -58,7 +61,7 @@ async def get_user(self, user_id: str, api_key: str, client_ip: str | None) -> a
# AccountFacilityAdapter methods
# -------------------------------------------------------------------------
- async def get_capabilities(self) -> list[account_models.Capability]:
+ async def get_capabilities(self) -> list[Capability]:
"""
coact.Cluster → IRI.Capability (compute)
Static storage types → IRI.Capability (storage)
@@ -70,35 +73,35 @@ async def get_capabilities(self) -> list[account_models.Capability]:
for cluster in clusters:
gpu_info = f", {cluster['nodegpucount']} GPUs" if cluster.get('nodegpucount', 0) > 0 else ""
- capabilities.append(account_models.Capability(
+ capabilities.append(Capability(
id=cluster["name"],
name=f"{cluster['name'].upper()} ({cluster['nodecpucount']} CPUs{gpu_info}, {cluster['nodememgb']}GB/node)",
- units=[account_models.AllocationUnit.node_hours]
+ units=[AllocationUnit.node_hours]
))
# Add storage capabilities
# capabilities.extend([
- # account_models.Capability(
+ # Capability(
# id="sdf-data",
# name="S3DF Data Storage - /sdf/data",
- # units=[account_models.AllocationUnit.bytes, account_models.AllocationUnit.inodes]
+ # units=[AllocationUnit.bytes, AllocationUnit.inodes]
# ),
- # account_models.Capability(
+ # Capability(
# id="sdf-group",
# name="S3DF Group Storage - /sdf/group",
- # units=[account_models.AllocationUnit.bytes, account_models.AllocationUnit.inodes]
+ # units=[AllocationUnit.bytes, AllocationUnit.inodes]
# ),
- # account_models.Capability(
+ # Capability(
# id="sdf-scratch",
# name="S3DF Scratch Storage - /sdf/scratch",
- # units=[account_models.AllocationUnit.bytes, account_models.AllocationUnit.inodes]
+ # units=[AllocationUnit.bytes, AllocationUnit.inodes]
# ),
# ])
return capabilities
- async def get_projects(self, user: account_models.User) -> list[account_models.Project]:
+ async def get_projects(self, user: User) -> list[account_models.Project]:
"""
coact.Repo → IRI.Project mapping:
- Id → id
@@ -127,7 +130,7 @@ async def get_projects(self, user: account_models.User) -> list[account_models.P
async def get_project_allocations(
self,
project: account_models.Project,
- user: account_models.User
+ user: User
) -> list[account_models.ProjectAllocation]:
"""
coact.RepoComputeAllocation → IRI.ProjectAllocation (node_hours)
@@ -153,7 +156,7 @@ async def get_project_allocations(
entries=[account_models.AllocationEntry(
allocation=comp_alloc.get("allocated", 0),
usage= overall_usage.get("resourceHours", 0) if overall_usage else 0,
- unit=account_models.AllocationUnit.node_hours
+ unit=AllocationUnit.node_hours
)]
))
@@ -162,7 +165,7 @@ async def get_project_allocations(
async def get_user_allocations(
self,
- user: account_models.User,
+ user: User,
project_allocation: account_models.ProjectAllocation
) -> list[account_models.UserAllocation]:
"""
diff --git a/app/s3df/auth/authenticated_adapter.py b/app/s3df/auth/authenticated_adapter.py
index 5e10fd86..b7bdfb61 100644
--- a/app/s3df/auth/authenticated_adapter.py
+++ b/app/s3df/auth/authenticated_adapter.py
@@ -23,7 +23,7 @@
class S3DFAuthenticatedAdapter:
"""Mixin: implements AuthenticatedAdapter.get_current_user via Dex JWT + CoAct."""
- async def get_current_user(self, api_key: str, client_ip: str | None) -> str:
+ async def get_current_user(self, api_key: str, client_ip: str | None, globus_introspect: dict | None = None) -> str:
if not api_key:
raise HTTPException(status_code=401, detail="Missing Authorization header")
@@ -36,3 +36,8 @@ async def get_current_user(self, api_key: str, client_ip: str | None) -> str:
# raise HTTPException(status_code=403, detail="User not authorized")
return username
+
+ async def get_current_user_globus(self, api_key: str, client_ip: str | None, globus_introspect: dict | None) -> str:
+ # Not implemented currently, return 501: Not Implemented
+
+ raise HTTPException(status_code=501, detail="Globus authentication not implemented yet")
diff --git a/app/s3df/compute_adapter.py b/app/s3df/compute_adapter.py
index dee75105..037152fa 100644
--- a/app/s3df/compute_adapter.py
+++ b/app/s3df/compute_adapter.py
@@ -18,7 +18,6 @@
import logging
import base64
from typing import Optional
-from ..routers.compute import models as compute_models
from ..routers.compute import facility_adapter as compute_adapter
from ..routers.compute.models import JobState
from app.s3df.auth.authenticated_adapter import S3DFAuthenticatedAdapter
@@ -199,7 +198,7 @@ class SLACComputeAdapter(S3DFAuthenticatedAdapter, compute_adapter.FacilityAdapt
# -- AuthenticatedAdapter methods ---------------------------------------
- async def get_user(self, user_id: str, api_key: str, client_ip: str | None):
+ async def get_user(self, user_id: str, api_key: str, client_ip: str | None, globus_introspect: dict | None = None):
"""
Return a minimal user object. The unix_username is the critical field —
it becomes the `sun` claim in the Slurm JWT.
diff --git a/app/types/__init__.py b/app/types/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/app/types/base.py b/app/types/base.py
new file mode 100644
index 00000000..c5619599
--- /dev/null
+++ b/app/types/base.py
@@ -0,0 +1,102 @@
+"""Default models used by multiple routers."""
+import datetime
+from collections.abc import Iterable
+
+from pydantic import BaseModel, ConfigDict, Field, computed_field, field_validator, model_serializer
+
+from .. import config
+from ..request_context import get_url_prefix
+from .scalars import StrictDateTime
+
+
+class IRIBaseModel(BaseModel):
+ """Base model for IRI models."""
+
+ model_config = ConfigDict(extra="allow")
+
+ @model_serializer(mode="wrap")
+ def _hide_extra(self, handler, info):
+ data = handler(self)
+
+ model_fields = set(self.model_fields or {})
+ computed_fields = set(self.model_computed_fields or {})
+ extra = getattr(self, "__pydantic_extra__", {}) or {}
+ for k in extra:
+ if k not in model_fields and k not in computed_fields:
+ data.pop(k, None)
+ return data
+
+ def get_extra(self, key, default=None):
+ """Get an extra field value that is not defined in the model. Returns default if not found."""
+ return getattr(self, "__pydantic_extra__", {}).get(key, default)
+
+ @classmethod
+ def normalize_dt(cls, dt: datetime) -> datetime:
+ """Normalize datetime to UTC-aware."""
+ # Convert naive datetimes into UTC-aware versions
+ if dt is None:
+ return None
+ if isinstance(dt, str):
+ dt = StrictDateTime.validate(dt)
+ if dt.tzinfo is None:
+ return dt.replace(tzinfo=datetime.timezone.utc)
+ return dt
+
+
+class NamedObject(IRIBaseModel):
+ """Base model for named objects."""
+
+ id: str = Field(..., description="The unique identifier for the object. Typically a UUID or URN.", example="urn:iri:object:1234")
+
+ def _self_path(self) -> str:
+ raise NotImplementedError
+
+ @field_validator("last_modified", mode="before")
+ @classmethod
+ def _norm_dt_field(cls, v):
+ return cls.normalize_dt(v)
+
+ @computed_field(description="The canonical URL of this object")
+ @property
+ def self_uri(self) -> str:
+ """Computed self URI property."""
+ return f"{get_url_prefix()}{self._self_path()}"
+
+ name: str|None = Field(default=None, description="The long name of the object.", example="Perlmutter GPU")
+ description: str|None = Field(default=None, description="Human-readable description of the object.", example="High-performance GPU compute resource")
+ last_modified: StrictDateTime = Field(..., description="ISO 8601 timestamp when this object was last modified.", example="2026-02-21T12:00:00Z")
+
+ @classmethod
+ def find_by_id(cls, items, id_, allow_name: bool = False):
+ """Find an object by its id or name == id."""
+ # Find a resource by its id.
+ # If allow_name is True, the id parameter can also match the resource's name.
+ matches = [r for r in items if r.id == id_ or (allow_name and r.name == id_)]
+ if not matches:
+ return None
+ if len(matches) > 1:
+ raise ValueError(f"Multiple {cls.__name__} objects matched identifier '{id_}'")
+
+ return matches[0]
+
+ @classmethod
+ def find(cls, items, name=None, description=None, modified_since=None):
+ """Find objects matching the given criteria."""
+ single = False
+ if not any((name, description, modified_since)):
+ return items
+
+ if not isinstance(items, Iterable) or isinstance(items, BaseModel):
+ items = [items]
+ single = True
+
+ if name:
+ items = [item for item in items if item.name == name]
+ if description:
+ items = [item for item in items if item.description and description in item.description]
+ if modified_since:
+ modified_since = cls.normalize_dt(modified_since)
+ items = [item for item in items if item.last_modified and item.last_modified >= modified_since]
+ if single:
+ return items[0] if items else None
+ return items
diff --git a/app/types/http.py b/app/types/http.py
new file mode 100644
index 00000000..4ee2a989
--- /dev/null
+++ b/app/types/http.py
@@ -0,0 +1,78 @@
+"""HTTP-related types and utilities for the IRI Facility API"""
+
+import datetime
+from email.utils import parsedate_to_datetime
+from urllib.parse import parse_qs
+
+from fastapi import HTTPException, Request, status
+
+from .scalars import StrictDateTime
+
+# -----------------------------------------------------------------------
+# modifiedSinceDatetime: combine modified_since (ISO8601) and If-Modified-Since (RFC1123)
+# If both are provided, the most recent timestamp is used. Strict validation is applied to both formats.
+# modified_since must be a valid ISO8601 datetime string.
+# If-Modified-Since must be a valid RFC1123 datetime string.
+# TODO: If-Modified-Since is not yet supported/used by the API.
+
+
+def modifiedSinceDatetime(modified_since: str | None, header_modified_since: str | None) -> datetime.datetime | None:
+ """
+ Combine modified_since (ISO8601) and If-Modified-Since (RFC1123).
+ If both are provided, the most recent timestamp is used.
+ """
+
+ parsed_times: list[datetime.datetime] = []
+
+ # Query param (ISO 8601)
+ if modified_since is not None:
+ try:
+ dt = StrictDateTime.validate(modified_since)
+ parsed_times.append(dt)
+ except ValueError as exc:
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"Invalid modified_since query param: {exc}") from exc
+
+ # Header (RFC 1123)
+ if header_modified_since is not None:
+ try:
+ dt = parsedate_to_datetime(header_modified_since)
+ if dt is None:
+ raise ValueError("Invalid RFC1123 date")
+ if dt.tzinfo is None:
+ dt = dt.replace(tzinfo=datetime.timezone.utc)
+ parsed_times.append(dt.astimezone(datetime.timezone.utc))
+ except Exception as exc:
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid If-Modified-Since header format (must be RFC1123)") from exc
+
+ if not parsed_times:
+ return None
+
+ # Stricter constraint wins
+ return max(parsed_times)
+
+
+# -----------------------------------------------------------------------
+# forbidExtraQueryParams: a dependency to forbid extra query parameters
+
+
+def forbidExtraQueryParams(*allowedParams: str, multiParams: set[str] | None = None):
+ """Dependency to forbid extra query parameters. If allowedParams contains "*", all params are allowed."""
+ multiParams = multiParams or set()
+
+ async def checker(req: Request):
+ if "*" in allowedParams:
+ return
+
+ raw_qs = req.scope.get("query_string", b"")
+ parsed = parse_qs(raw_qs.decode("utf-8", errors="strict"), keep_blank_values=True)
+
+ allowed = set(allowedParams)
+
+ for key, values in parsed.items():
+ if key not in allowed:
+ raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=[{"type": "extra_forbidden", "loc": ["query", key], "msg": f"Unexpected query parameter: {key}"}])
+
+ if len(values) > 1 and key not in multiParams:
+ raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=[{"type": "duplicate_forbidden", "loc": ["query", key], "msg": f"Duplicate query parameter: {key}"}])
+
+ return checker
diff --git a/app/types/models.py b/app/types/models.py
new file mode 100644
index 00000000..113e5dc5
--- /dev/null
+++ b/app/types/models.py
@@ -0,0 +1,23 @@
+"""Models for the IRI Facility API."""
+
+from pydantic import Field
+
+from .base import NamedObject
+from .scalars import AllocationUnit, StrictDateTime
+
+
+class Capability(NamedObject):
+ """
+ An aspect of a resource that can have an allocation.
+ For example, Perlmutter nodes with GPUs
+ For some resources at a facility, this will be 1 to 1 with the resource.
+ It is a way to further subdivide a resource into allocatable sub-resources.
+ The word "capability" is also known to users as something they need for a job to run. (eg. gpu)
+ """
+
+ def _self_path(self) -> str:
+ return f"/account/capabilities/{self.id}"
+
+ last_modified: StrictDateTime|None = Field(default=None, description="ISO 8601 timestamp when this object was last modified.", example="2026-02-21T12:00:00Z")
+
+ units: list[AllocationUnit] = Field(..., description="Allocation units supported by this capability", example=["node_hours"])
diff --git a/app/types/scalars.py b/app/types/scalars.py
new file mode 100644
index 00000000..365be066
--- /dev/null
+++ b/app/types/scalars.py
@@ -0,0 +1,92 @@
+"""Scalar types for the IRI Facility API"""
+
+# pylint: disable=unused-argument
+import datetime
+import enum
+
+from pydantic_core import core_schema
+
+
+# -----------------------------------------------------------------------
+# StrictHTTPBool: a strict boolean type
+class StrictHTTPBool:
+ """Strict boolean:
+ - Accepts: real booleans, 'true', 'false'
+ - Rejects everything else.
+ """
+
+ @classmethod
+ def __get_pydantic_core_schema__(cls, source, handler):
+ return core_schema.no_info_plain_validator_function(cls.validate)
+
+ @staticmethod
+ def validate(value):
+ """Validate the input value as a strict boolean."""
+ if isinstance(value, bool):
+ return value
+ if isinstance(value, str):
+ v = value.strip().lower()
+ if v == "true":
+ return True
+ if v == "false":
+ return False
+ raise ValueError("Invalid boolean value. Expected 'true' or 'false'.")
+ raise ValueError("Invalid boolean value. Expected true/false or 'true'/'false'.")
+
+ @classmethod
+ def __get_pydantic_json_schema__(cls, schema, handler):
+ return {"type": "boolean", "description": "Strict boolean. Only true/false allowed (bool or string).", "example": True}
+
+
+# -----------------------------------------------------------------------
+# StrictDateTime: a strict ISO8601 datetime type
+class StrictDateTime:
+ """
+ Strict ISO8601 datetime:
+ - Accepts datetime objects
+ - Accepts ISO8601 strings: 2025-12-06T10:00:00Z, 2025-12-06T10:00:00+00:00
+ - Converts 'Z' → UTC
+ - Converts naive datetimes → UTC
+ - Rejects integers ("0"), null, garbage strings, etc.
+ """
+
+ @classmethod
+ def __get_pydantic_core_schema__(cls, source, handler):
+ return core_schema.no_info_plain_validator_function(cls.validate)
+
+ @staticmethod
+ def validate(value):
+ """Validate the input value as a strict ISO8601 datetime."""
+ if isinstance(value, datetime.datetime):
+ return StrictDateTime._normalize(value)
+ if not isinstance(value, str):
+ raise ValueError("Invalid datetime value. Expected ISO8601 datetime string.")
+ v = value.strip()
+ if v.endswith("Z"):
+ v = v[:-1] + "+00:00"
+ try:
+ dt = datetime.datetime.fromisoformat(v)
+ except Exception as ex:
+ raise ValueError("Invalid datetime format. Expected ISO8601 string.") from ex
+
+ return StrictDateTime._normalize(dt)
+
+ @staticmethod
+ def _normalize(dt: datetime.datetime) -> datetime.datetime:
+ if dt.tzinfo is None:
+ return dt.replace(tzinfo=datetime.timezone.utc)
+ return dt
+
+ @classmethod
+ def __get_pydantic_json_schema__(cls, schema, handler):
+ return {"type": "string", "format": "date-time", "description": "Strict ISO8601 datetime. Only valid ISO8601 datetime strings are accepted.", "example": "2026-02-21T12:00:00Z"}
+
+
+# -----------------------------------------------------------------------
+# AllocationUnit: an enum for allocation units
+class AllocationUnit(enum.Enum):
+ """Units for allocation"""
+
+ node_hours = "node_hours"
+ bytes = "bytes"
+ inodes = "inodes"
diff --git a/app/types/user.py b/app/types/user.py
new file mode 100644
index 00000000..fa9db2b4
--- /dev/null
+++ b/app/types/user.py
@@ -0,0 +1,12 @@
+from pydantic import Field
+from ..types.base import IRIBaseModel
+
+
+class User(IRIBaseModel):
+ """A user of the facility"""
+
+ id: str = Field(..., description="Unique identifier of the user.", example="user-123")
+ name: str = Field(..., description="Name of the user.", example="Jane Doe")
+ api_key: str = Field(..., description="API key associated with this user.", example="AKIAIOSFODNN7EXAMPLE")
+ client_ip: str|None = Field(default=None, description="IP address from which the user connects.", example="192.0.2.10")
+ # we could expose more fields here (eg. email) but it might be against policy
\ No newline at end of file
diff --git a/gunicorn.config.py b/gunicorn.config.py
index 81077cee..e05cf888 100644
--- a/gunicorn.config.py
+++ b/gunicorn.config.py
@@ -4,6 +4,6 @@
logging.getLogger().setLevel(logging.INFO)
workers = 8
-worker_class = "uvicorn.workers.UvicornWorker"
+worker_class = "uvicorn.workers.UvicornWorker"
bind = "0.0.0.0:8000"
timeout = 60
diff --git a/local-template.env b/local-template.env
new file mode 100644
index 00000000..68ad9b0c
--- /dev/null
+++ b/local-template.env
@@ -0,0 +1,9 @@
+# globus app credentials
+export GLOBUS_APP_ID=
+export GLOBUS_APP_SECRET=
+
+# the resource server's credentials
+export GLOBUS_RS_ID=ed3e577d-f7f3-4639-b96e-ff5a8445d699
+export GLOBUS_RS_SECRET=
+
+export GLOBUS_RS_SCOPE_SUFFIX=iri_api
diff --git a/pylintrc b/pylintrc
new file mode 100644
index 00000000..99b31968
--- /dev/null
+++ b/pylintrc
@@ -0,0 +1,581 @@
+[MASTER]
+
+# A comma-separated list of package or module names from where C extensions may
+# be loaded. Extensions are loading into the active Python interpreter and may
+# run arbitrary code.
+extension-pkg-allow-list=
+
+# A comma-separated list of package or module names from where C extensions may
+# be loaded. Extensions are loading into the active Python interpreter and may
+# run arbitrary code. (This is an alternative name to extension-pkg-allow-list
+# for backward compatibility.)
+extension-pkg-whitelist=
+
+# Return non-zero exit code if any of these messages/categories are detected,
+# even if score is above --fail-under value. Syntax same as enable. Messages
+# specified are enabled, while categories only check already-enabled messages.
+fail-on=
+
+# Specify a score threshold to be exceeded before program exits with error.
+fail-under=10.0
+
+# Files or directories to be skipped. They should be base names, not paths.
+ignore=CVS
+
+# Add files or directories matching the regex patterns to the ignore-list. The
+# regex matches against paths and can be in Posix or Windows format.
+ignore-paths=
+
+# Files or directories matching the regex patterns are skipped. The regex
+# matches against base names, not paths. The default value ignores emacs file
+# locks
+ignore-patterns=^\.#
+
+# Python code to execute, usually for sys.path manipulation such as
+# pygtk.require().
+#init-hook=
+
+# Use multiple processes to speed up Pylint. Specifying 0 will auto-detect the
+# number of processors available to use.
+jobs=1
+
+# Control the amount of potential inferred values when inferring a single
+# object. This can help the performance when dealing with large functions or
+# complex, nested conditions.
+limit-inference-results=100
+
+# List of plugins (as comma separated values of python module names) to load,
+# usually to register additional checkers.
+load-plugins=
+
+# Pickle collected data for later comparisons.
+persistent=yes
+
+# Minimum Python version to use for version dependent checks. Will default to
+# the version used to run pylint.
+py-version=3.13
+
+# Discover python modules and packages in the file system subtree.
+recursive=no
+
+# Allow loading of arbitrary C extensions. Extensions are imported into the
+# active Python interpreter and may run arbitrary code.
+unsafe-load-any-extension=no
+
+
+[MESSAGES CONTROL]
+
+# Only show warnings with the listed confidence levels. Leave empty to show
+# all. Valid levels: HIGH, CONTROL_FLOW, INFERENCE, INFERENCE_FAILURE,
+# UNDEFINED.
+confidence=
+
+# Disable the message, report, category or checker with the given id(s). You
+# can either give multiple identifiers separated by comma (,) or put this
+# option multiple times (only on the command line, not in the configuration
+# file where it should appear only once). You can also use "--disable=all" to
+# disable everything first and then re-enable specific checks. For example, if
+# you want to run only the similarities checker, you can use "--disable=all
+# --enable=similarities". If you want to run only the classes checker, but have
+# no Warning level messages displayed, use "--disable=all --enable=classes
+# --disable=W".
+disable=missing-function-docstring,
+ too-many-positional-arguments,
+ too-many-arguments,
+ too-many-positional-arguments,
+ missing-class-docstring,
+ invalid-name,
+ missing-module-docstring
+
+# Enable the message, report, category or checker with the given id(s). You can
+# either give multiple identifier separated by comma (,) or put this option
+# multiple time (only on the command line, not in the configuration file where
+# it should appear only once). See also the "--disable" option for examples.
+enable=c-extension-no-member
+
+
+[REPORTS]
+
+# Python expression which should return a score less than or equal to 10. You
+# have access to the variables 'fatal', 'error', 'warning', 'refactor',
+# 'convention', and 'info' which contain the number of messages in each
+# category, as well as 'statement' which is the total number of statements
+# analyzed. This score is used by the global evaluation report (RP0004).
+evaluation=max(0, 0 if fatal else 10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10))
+
+# Template used to display messages. This is a python new-style format string
+# used to format the message information. See doc for all details.
+#msg-template=
+
+# Set the output format. Available formats are text, parseable, colorized, json
+# and msvs (visual studio). You can also give a reporter class, e.g.
+# mypackage.mymodule.MyReporterClass.
+output-format=text
+
+# Tells whether to display a full report or only the messages.
+reports=no
+
+# Activate the evaluation score.
+score=yes
+
+
+[REFACTORING]
+
+# Maximum number of nested blocks for function / method body
+max-nested-blocks=5
+
+# Complete name of functions that never returns. When checking for
+# inconsistent-return-statements if a never returning function is called then
+# it will be considered as an explicit return statement and no message will be
+# printed.
+never-returning-functions=sys.exit,argparse.parse_error
+
+
+[FORMAT]
+
+# Expected format of line ending, e.g. empty (any line ending), LF or CRLF.
+expected-line-ending-format=
+
+# Regexp for a line that is allowed to be longer than the limit.
+ignore-long-lines=^\s*(# )??$
+
+# Number of spaces of indent required inside a hanging or continued line.
+indent-after-paren=4
+
+# String used as indentation unit. This is usually " " (4 spaces) or "\t" (1
+# tab).
+indent-string=' '
+
+# Maximum number of characters on a single line.
+max-line-length=200
+
+# Maximum number of lines in a module.
+max-module-lines=1000
+
+# Allow the body of a class to be on the same line as the declaration if body
+# contains single statement.
+single-line-class-stmt=no
+
+# Allow the body of an if to be on the same line as the test if there is no
+# else.
+single-line-if-stmt=no
+
+
+[LOGGING]
+
+# The type of string formatting that logging methods do. `old` means using %
+# formatting, `new` is for `{}` formatting.
+logging-format-style=old
+
+# Logging modules to check that the string format arguments are in logging
+# function parameter format.
+logging-modules=logging
+
+
+[MISCELLANEOUS]
+
+# List of note tags to take in consideration, separated by a comma.
+notes=FIXME,
+ XXX,
+ TODO
+
+# Regular expression of note tags to take in consideration.
+#notes-rgx=
+
+
+[SIMILARITIES]
+
+# Comments are removed from the similarity computation
+ignore-comments=yes
+
+# Docstrings are removed from the similarity computation
+ignore-docstrings=yes
+
+# Imports are removed from the similarity computation
+ignore-imports=no
+
+# Signatures are removed from the similarity computation
+ignore-signatures=no
+
+# Minimum lines number of a similarity.
+min-similarity-lines=4
+
+
+[SPELLING]
+
+# Limits count of emitted suggestions for spelling mistakes.
+max-spelling-suggestions=4
+
+# Spelling dictionary name. Available dictionaries: none. To make it work,
+# install the 'python-enchant' package.
+spelling-dict=
+
+# List of comma separated words that should be considered directives if they
+# appear and the beginning of a comment and should not be checked.
+spelling-ignore-comment-directives=fmt: on,fmt: off,noqa:,noqa,nosec,isort:skip,mypy:
+
+# List of comma separated words that should not be checked.
+spelling-ignore-words=
+
+# A path to a file that contains the private dictionary; one word per line.
+spelling-private-dict-file=
+
+# Tells whether to store unknown words to the private dictionary (see the
+# --spelling-private-dict-file option) instead of raising a message.
+spelling-store-unknown-words=no
+
+
+[STRING]
+
+# This flag controls whether inconsistent-quotes generates a warning when the
+# character used as a quote delimiter is used inconsistently within a module.
+check-quote-consistency=no
+
+# This flag controls whether the implicit-str-concat should generate a warning
+# on implicit string concatenation in sequences defined over several lines.
+check-str-concat-over-line-jumps=no
+
+
+[TYPECHECK]
+
+# List of decorators that produce context managers, such as
+# contextlib.contextmanager. Add to this list to register other decorators that
+# produce valid context managers.
+contextmanager-decorators=contextlib.contextmanager
+
+# List of members which are set dynamically and missed by pylint inference
+# system, and so shouldn't trigger E1101 when accessed. Python regular
+# expressions are accepted.
+generated-members=
+
+# Tells whether missing members accessed in mixin class should be ignored. A
+# class is considered mixin if its name matches the mixin-class-rgx option.
+ignore-mixin-members=yes
+
+# Tells whether to warn about missing members when the owner of the attribute
+# is inferred to be None.
+ignore-none=yes
+
+# This flag controls whether pylint should warn about no-member and similar
+# checks whenever an opaque object is returned when inferring. The inference
+# can return multiple potential results while evaluating a Python object, but
+# some branches might not be evaluated, which results in partial inference. In
+# that case, it might be useful to still emit no-member and other checks for
+# the rest of the inferred objects.
+ignore-on-opaque-inference=yes
+
+# List of class names for which member attributes should not be checked (useful
+# for classes with dynamically set attributes). This supports the use of
+# qualified names.
+ignored-classes=optparse.Values,thread._local,_thread._local
+
+# List of module names for which member attributes should not be checked
+# (useful for modules/projects where namespaces are manipulated during runtime
+# and thus existing member attributes cannot be deduced by static analysis). It
+# supports qualified module names, as well as Unix pattern matching.
+ignored-modules=
+
+# Show a hint with possible names when a member name was not found. The aspect
+# of finding the hint is based on edit distance.
+missing-member-hint=yes
+
+# The minimum edit distance a name should have in order to be considered a
+# similar match for a missing member name.
+missing-member-hint-distance=1
+
+# The total number of similar names that should be taken in consideration when
+# showing a hint for a missing member.
+missing-member-max-choices=1
+
+# Regex pattern to define which classes are considered mixins ignore-mixin-
+# members is set to 'yes'
+mixin-class-rgx=.*[Mm]ixin
+
+# List of decorators that change the signature of a decorated function.
+signature-mutators=
+
+
+[VARIABLES]
+
+# List of additional names supposed to be defined in builtins. Remember that
+# you should avoid defining new builtins when possible.
+additional-builtins=
+
+# Tells whether unused global variables should be treated as a violation.
+allow-global-unused-variables=yes
+
+# List of names allowed to shadow builtins
+allowed-redefined-builtins=
+
+# List of strings which can identify a callback function by name. A callback
+# name must start or end with one of those strings.
+callbacks=cb_,
+ _cb
+
+# A regular expression matching the name of dummy variables (i.e. expected to
+# not be used).
+dummy-variables-rgx=_+$|(_[a-zA-Z0-9_]*[a-zA-Z0-9]+?$)|dummy|^ignored_|^unused_
+
+# Argument names that match this expression will be ignored. Default to name
+# with leading underscore.
+ignored-argument-names=_.*|^ignored_|^unused_
+
+# Tells whether we should check for unused import in __init__ files.
+init-import=no
+
+# List of qualified module names which can have objects that can redefine
+# builtins.
+redefining-builtins-modules=six.moves,past.builtins,future.builtins,builtins,io
+
+
+[BASIC]
+
+# Naming style matching correct argument names.
+argument-naming-style=camelCase
+
+# Regular expression matching correct argument names. Overrides argument-
+# naming-style. If left empty, argument names will be checked with the set
+# naming style.
+#argument-rgx=
+
+# Naming style matching correct attribute names.
+attr-naming-style=camelCase
+
+# Regular expression matching correct attribute names. Overrides attr-naming-
+# style. If left empty, attribute names will be checked with the set naming
+# style.
+#attr-rgx=
+
+# Bad variable names which should always be refused, separated by a comma.
+bad-names=foo,
+ bar,
+ baz,
+ toto,
+ tutu,
+ tata
+
+# Bad variable names regexes, separated by a comma. If names match any regex,
+# they will always be refused
+bad-names-rgxs=
+
+# Naming style matching correct class attribute names.
+class-attribute-naming-style=any
+
+# Regular expression matching correct class attribute names. Overrides class-
+# attribute-naming-style. If left empty, class attribute names will be checked
+# with the set naming style.
+#class-attribute-rgx=
+
+# Naming style matching correct class constant names.
+class-const-naming-style=UPPER_CASE
+
+# Regular expression matching correct class constant names. Overrides class-
+# const-naming-style. If left empty, class constant names will be checked with
+# the set naming style.
+#class-const-rgx=
+
+# Naming style matching correct class names.
+class-naming-style=PascalCase
+
+# Regular expression matching correct class names. Overrides class-naming-
+# style. If left empty, class names will be checked with the set naming style.
+#class-rgx=
+
+# Naming style matching correct constant names.
+const-naming-style=UPPER_CASE
+
+# Regular expression matching correct constant names. Overrides const-naming-
+# style. If left empty, constant names will be checked with the set naming
+# style.
+#const-rgx=
+
+# Minimum line length for functions/classes that require docstrings, shorter
+# ones are exempt.
+docstring-min-length=-1
+
+# Naming style matching correct function names.
+function-naming-style=camelCase
+
+# Regular expression matching correct function names. Overrides function-
+# naming-style. If left empty, function names will be checked with the set
+# naming style.
+#function-rgx=
+
+# Good variable names which should always be accepted, separated by a comma.
+good-names=i,
+ j,
+ k,
+ ex,
+ Run,
+ _
+
+# Good variable names regexes, separated by a comma. If names match any regex,
+# they will always be accepted
+good-names-rgxs=
+
+# Include a hint for the correct naming format with invalid-name.
+include-naming-hint=no
+
+# Naming style matching correct inline iteration names.
+inlinevar-naming-style=any
+
+# Regular expression matching correct inline iteration names. Overrides
+# inlinevar-naming-style. If left empty, inline iteration names will be checked
+# with the set naming style.
+#inlinevar-rgx=
+
+# Naming style matching correct method names.
+method-naming-style=camelCase
+method-rgx=^(_{1,2}[a-z][a-zA-Z0-9]*|[a-z][a-zA-Z0-9]*)$
+
+# Regular expression matching correct method names. Overrides method-naming-
+# style. If left empty, method names will be checked with the set naming style.
+#method-rgx=
+
+# Naming style matching correct module names.
+module-naming-style=camelCase
+
+# Regular expression matching correct module names. Overrides module-naming-
+# style. If left empty, module names will be checked with the set naming style.
+#module-rgx=
+
+# Colon-delimited sets of names that determine each other's naming style when
+# the name regexes allow several styles.
+name-group=
+
+# Regular expression which should only match function or class names that do
+# not require a docstring.
+no-docstring-rgx=^_
+
+# List of decorators that produce properties, such as abc.abstractproperty. Add
+# to this list to register other decorators that produce valid properties.
+# These decorators are taken in consideration only for invalid-name.
+property-classes=abc.abstractproperty
+
+# Regular expression matching correct type variable names. If left empty, type
+# variable names will be checked with the set naming style.
+#typevar-rgx=
+
+# Naming style matching correct variable names.
+variable-naming-style=camelCase
+
+# Regular expression matching correct variable names. Overrides variable-
+# naming-style. If left empty, variable names will be checked with the set
+# naming style.
+#variable-rgx=
+
+
+[DESIGN]
+
+# List of regular expressions of class ancestor names to ignore when counting
+# public methods (see R0903)
+exclude-too-few-public-methods=
+
+# List of qualified class names to ignore when counting class parents (see
+# R0901)
+ignored-parents=
+
+# Maximum number of arguments for function / method.
+max-args=5
+
+# Maximum number of attributes for a class (see R0902).
+max-attributes=7
+
+# Maximum number of boolean expressions in an if statement (see R0916).
+max-bool-expr=5
+
+# Maximum number of branch for function / method body.
+max-branches=12
+
+# Maximum number of locals for function / method body.
+max-locals=15
+
+# Maximum number of parents for a class (see R0901).
+max-parents=7
+
+# Maximum number of public methods for a class (see R0904).
+max-public-methods=20
+
+# Maximum number of return / yield for function / method body.
+max-returns=6
+
+# Maximum number of statements in function / method body.
+max-statements=50
+
+# Minimum number of public methods for a class (see R0903).
+min-public-methods=2
+
+
+[IMPORTS]
+
+# List of modules that can be imported at any level, not just the top level
+# one.
+allow-any-import-level=
+
+# Allow wildcard imports from modules that define __all__.
+allow-wildcard-with-all=no
+
+# Analyse import fallback blocks. This can be used to support both Python 2 and
+# 3 compatible code, which means that the block might have code that exists
+# only in one or another interpreter, leading to false positives when analysed.
+analyse-fallback-blocks=no
+
+# Deprecated modules which should not be used, separated by a comma.
+deprecated-modules=
+
+# Output a graph (.gv or any supported image format) of external dependencies
+# to the given file (report RP0402 must not be disabled).
+ext-import-graph=
+
+# Output a graph (.gv or any supported image format) of all (i.e. internal and
+# external) dependencies to the given file (report RP0402 must not be
+# disabled).
+import-graph=
+
+# Output a graph (.gv or any supported image format) of internal dependencies
+# to the given file (report RP0402 must not be disabled).
+int-import-graph=
+
+# Force import order to recognize a module as part of the standard
+# compatibility libraries.
+known-standard-library=
+
+# Force import order to recognize a module as part of a third party library.
+known-third-party=enchant
+
+# Couples of modules and preferred modules, separated by a comma.
+preferred-modules=
+
+
+[CLASSES]
+
+# Warn about protected attribute access inside special methods
+check-protected-access-in-special-methods=no
+
+# List of method names used to declare (i.e. assign) instance attributes.
+defining-attr-methods=__init__,
+ __new__,
+ setUp,
+ __post_init__
+
+# List of member names, which should be excluded from the protected access
+# warning.
+exclude-protected=_asdict,
+ _fields,
+ _replace,
+ _source,
+ _make
+
+# List of valid names for the first argument in a class method.
+valid-classmethod-first-arg=cls
+
+# List of valid names for the first argument in a metaclass class method.
+valid-metaclass-classmethod-first-arg=cls
+
+
+[EXCEPTIONS]
+
+# Exceptions that will emit a warning when being caught. Defaults to
+# "BaseException, Exception".
+overgeneral-exceptions=builtins.BaseException,
+ builtins.Exception
diff --git a/pyproject.toml b/pyproject.toml
index a1969e5e..a4ed9785 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,10 +1,16 @@
[project]
name = "iri-api-python"
version = "0.1.0"
-requires-python = ">=3.12"
+requires-python = ">=3.13,<3.14"
dependencies = [
- "fastapi[standard]>=0.100.0",
- "uvicorn[standard]>=0.22.0",
+ "fastapi[standard]>=0.128.0,<0.129.0",
+ "uvicorn[standard]>=0.40.0,<0.41.0",
+ "opentelemetry-api>=1.39.1,<1.40.0",
+ "opentelemetry-sdk>=1.39.1,<1.40.0",
+ "opentelemetry-instrumentation-fastapi>=0.60b1,<0.61b0",
+ "opentelemetry-exporter-otlp>=1.39.1,<1.40.0",
+ "globus-sdk>=4.3.1",
+ "typer>=0.24.1",
"humps>=0.2.2",
"gql[httpx]>=3.5.0",
"pyjwt[crypto]>=2.8.0",
@@ -20,3 +26,6 @@ dev = [
"pytest-asyncio>=0.23",
]
+[tool.ruff]
+line-length = 200
+exclude = [".venv", "__pycache__", "build", "dist"]
diff --git a/test/test_filesystem.py b/test/test_filesystem.py
new file mode 100644
index 00000000..e83f6137
--- /dev/null
+++ b/test/test_filesystem.py
@@ -0,0 +1,301 @@
+#!/usr/bin/env python3
+"""
+IRI Filesystem API smoke test via async tasks.
+"""
+import os
+import sys
+import time
+import datetime as dt
+import requests
+
+
+# =========================
+# CONFIG — EDIT THESE AS NEEDED
+# =========================
+
+BASE_URL = "http://localhost:8000/api/v1"
+#BASE_URL = "https://api.iri.nersc.gov/api/v1"
+#BASE_URL = "https://iri-dev.ppg.es.net/api/v1"
+TOKEN = os.environ.get("IRI_API_TOKEN", "12345")
+# =========================
+HEADERS = {"Authorization": f"Bearer {TOKEN}", "Accept": "application/json"}
+POLL_INTERVAL = 2
+TIMEOUT = 180
+
+
+def print_table(headers, rows):
+ """Pretty-print a table with dynamic column width."""
+ widths = [len(h) for h in headers]
+
+ for row in rows:
+ for i, cell in enumerate(row):
+ widths[i] = max(widths[i], len(str(cell)))
+
+ fmt = " | ".join(f"{{:<{w}}}" for w in widths)
+ sep = "-+-".join("-" * w for w in widths)
+
+ print(fmt.format(*headers))
+ print(sep)
+ for row in rows:
+ print(fmt.format(*[str(c) for c in row]))
+ print()
+
+def cap_name(allcaps, uri):
+ """Get capability name from URI, or return URI if not found."""
+ c = allcaps.get(uri)
+ return c.get("name") if c else uri
+
+def getAllResources():
+ """Get and print all storage resources available to the user, along with their capabilities."""
+ print("\n" + "="*40)
+ print("=== DISCOVERING RESOURCES AND CAPABILITIES ===")
+ # -----------------------------
+ # Projects
+ # -----------------------------
+ projects = requests.get(f"{BASE_URL}/account/projects", headers=HEADERS, timeout=TIMEOUT).json()
+ project_rows = [[p.get("id"), p.get("name", ""), p.get("description", "")] for p in projects]
+
+ print("\n=== PROJECTS ===")
+ print_table(["Project ID", "Name", "Description"], project_rows)
+
+ # -----------------------------
+ # Capabilities
+ # -----------------------------
+ caps = requests.get(f"{BASE_URL}/account/capabilities", headers=HEADERS, timeout=TIMEOUT).json()
+
+ cap_rows = [[c.get("self_uri"), c.get("name"), c.get("description", "")] for c in caps]
+ cap_by_uri = {c["self_uri"]: c for c in caps}
+
+ print("\n=== CAPABILITIES ===")
+ print_table(["Capability URI", "Name", "Description"], cap_rows)
+
+
+ # -----------------------------
+ # Allocations per project
+ # -----------------------------
+ alloc_rows = []
+ projectStorageCaps = set()
+
+ for pr in projects:
+ allocs = requests.get(f"{BASE_URL}/account/projects/{pr['id']}/project_allocations", headers=HEADERS, timeout=TIMEOUT).json()
+
+ for a in allocs:
+ alloc_rows.append([pr["id"], a.get("id"), cap_name(cap_by_uri, a.get("capability_uri"))])
+
+ if a.get("capability_uri") in cap_by_uri:
+ projectStorageCaps.add(a["capability_uri"])
+
+ print("\n=== PROJECT ALLOCATIONS ===")
+ print_table(["Project ID", "Allocation ID", "Capability URI"], alloc_rows)
+
+ if not projectStorageCaps:
+ die("No storage allocations found")
+
+ # -----------------------------
+ # Resources
+ # -----------------------------
+ resources = requests.get(f"{BASE_URL}/status/resources?offset=0&limit=100", headers=HEADERS, timeout=TIMEOUT).json()
+
+ resource_rows = []
+ matching = []
+
+ for r in resources:
+ caps = r.get("capability_uris", [])
+ cap_names = [cap_name(cap_by_uri, c) for c in caps]
+
+ match = any(cap in caps for cap in projectStorageCaps)
+
+ if match:
+ resource_rows.append([r.get("id"), r.get("name", ""), r.get("resource_type", ""),
+ r.get("description", ""), ", ".join(cap_names), r.get("current_status", "")])
+ matching.append(r["id"])
+
+ print("\n=== RESOURCES ===")
+ print_table(["Resource ID", "Name", "Type", "Description", "Capability URIs", "Current Status"], resource_rows)
+
+ if not matching:
+ die("No storage resources found")
+
+getAllResources()
+print("\n" + "="*40)
+RESOURCE_ID = input("Enter the ID of the storage resource to test against: \n").strip()
+print("Chosen storage resource ID:", RESOURCE_ID)
+
+
+def die(msg):
+ """Print error message and exit."""
+ print(f"\nERROR: {msg}")
+ sys.exit(1)
+
+
+def submit(method, path, **kwargs):
+ """Submit a task and return its ID."""
+ print(f"Submitting {method} {path} with {kwargs}")
+ url = f"{BASE_URL}{path}"
+ r = requests.request(method, url, headers=HEADERS, timeout=TIMEOUT, **kwargs)
+
+ if not r.ok:
+ die(f"{method} {url} failed: {r.status_code} {r.text}")
+
+ data = r.json()
+ if not data.get("task_id"):
+ die(f"No task_id in response: {data}")
+ if not data.get("task_uri"):
+ die(f"No task_uri in response: {data}")
+
+ return data
+
+
+def wait_task(taskin):
+ """Wait for a task to complete and return its result."""
+ deadline = time.time() + TIMEOUT
+
+ while time.time() < deadline:
+ r = requests.get(taskin["task_uri"], headers=HEADERS, timeout=TIMEOUT)
+
+ if not r.ok:
+ die(f"Task query failed: {r.status_code} {r.text}")
+
+ t = r.json()
+ status = t["status"]
+
+ print(f" Task {taskin['task_id']}: {status}")
+
+ if status == "completed":
+ print(f" Task result: {t.get('result')}")
+ return t.get("result")
+
+ if status in ("failed", "canceled"):
+ die(f"Task {taskin['task_id']} ended with status {status}: {t}")
+
+ time.sleep(POLL_INTERVAL)
+
+ die(f"Task {taskin['task_id']} timed out")
+
+
+# ============================================================
+# Sandbox setup
+# ============================================================
+timestamp = dt.datetime.now(dt.UTC).strftime("%Y%m%d-%H%M%S")
+
+# NOTE/TODO: /Users/jbalcas/work/amsc/iri/iri-facility-api-python/iri_sandbox/
+# While we can use absolute paths, there is a need to return relative paths from the API
+# As this directory can be mounted at different locations at different facilities
+base_dir = f"iri-fs-test-{timestamp}"
+file_path = f"{base_dir}/hello.txt"
+copy_path = f"{base_dir}/hello_copy.txt"
+moved_path = f"{base_dir}/hello_moved.txt"
+link_path = f"{base_dir}/hello_link.txt"
+archive_path = f"{base_dir}.tar.gz"
+extract_dir = f"{base_dir}_extracted"
+
+content = f"hello world {timestamp}\n"
+
+
+print("\n" + "="*40)
+print("=== CREATE DIRECTORY ===")
+
+task = submit("POST", f"/filesystem/mkdir/{RESOURCE_ID}", json={"path": base_dir, "parent": True})
+wait_task(task)
+
+print("\n" + "="*40)
+print("=== UPLOAD FILE ===")
+
+task = submit("POST", f"/filesystem/upload/{RESOURCE_ID}?path={file_path}", files={"file": ("hello.txt", content.encode())})
+wait_task(task)
+
+print("\n" + "="*40)
+print("=== FILE TYPE ===")
+
+task = submit("GET", f"/filesystem/file/{RESOURCE_ID}", params={"path": file_path})
+wait_task(task)
+
+print("\n" + "="*40)
+print("=== STAT ===")
+
+task = submit("GET", f"/filesystem/stat/{RESOURCE_ID}", params={"path": file_path})
+wait_task(task)
+
+print("\n" + "="*40)
+print("=== LS ===")
+
+task = submit("GET", f"/filesystem/ls/{RESOURCE_ID}", params={"path": base_dir})
+wait_task(task)
+
+print("\n" + "="*40)
+print("=== CHMOD ===")
+
+task = submit("PUT", f"/filesystem/chmod/{RESOURCE_ID}", json={"path": file_path, "mode": "644"})
+wait_task(task)
+
+print("\n" + "="*40)
+print("=== HEAD ===")
+
+task = submit("GET", f"/filesystem/head/{RESOURCE_ID}", params={"path": file_path, "lines": 1})
+wait_task(task)
+
+print("\n" + "="*40)
+print("=== TAIL ===")
+
+task = submit("GET", f"/filesystem/tail/{RESOURCE_ID}", params={"path": file_path, "lines": 1})
+wait_task(task)
+
+print("\n" + "="*40)
+print("=== VIEW ===")
+
+task = submit("GET", f"/filesystem/view/{RESOURCE_ID}", params={"path": file_path, "size": 4096, "offset": 0})
+wait_task(task)
+
+print("\n" + "="*40)
+print("=== CHECKSUM ===")
+
+task = submit("GET", f"/filesystem/checksum/{RESOURCE_ID}", params={"path": file_path})
+wait_task(task)
+
+print("\n" + "="*40)
+print("=== COPY FILE ===")
+
+# Keep this as source_path. Server accepts both, so making sure it works.
+task = submit("POST", f"/filesystem/cp/{RESOURCE_ID}", json={"source_path": file_path, "target_path": copy_path})
+wait_task(task)
+
+print("\n" + "="*40)
+print("=== MOVE FILE ===")
+
+task = submit("POST", f"/filesystem/mv/{RESOURCE_ID}", json={"source_path": copy_path, "target_path": moved_path})
+wait_task(task)
+
+print("\n" + "="*40)
+print("=== CREATE SYMLINK ===")
+
+task = submit("POST", f"/filesystem/symlink/{RESOURCE_ID}", json={"path": moved_path, "link_path": link_path})
+wait_task(task)
+
+print("\n" + "="*40)
+print("=== COMPRESS DIRECTORY ===")
+
+task = submit("POST", f"/filesystem/compress/{RESOURCE_ID}", json={"source_path": base_dir, "target_path": archive_path, "compression": "gzip"})
+wait_task(task)
+
+print("\n" + "="*40)
+print("=== EXTRACT ARCHIVE ===")
+
+task = submit("POST", f"/filesystem/extract/{RESOURCE_ID}", json={"source_path": archive_path, "target_path": extract_dir, "compression": "gzip"})
+wait_task(task)
+
+print("\n" + "="*40)
+print("=== DOWNLOAD FILE ===")
+
+task = submit("GET", f"/filesystem/download/{RESOURCE_ID}", params={"path": moved_path})
+wait_task(task)
+
+print("\n" + "="*40)
+print("=== CLEANUP ===")
+
+for p in [base_dir, archive_path, extract_dir]:
+ task = submit("DELETE", f"/filesystem/rm/{RESOURCE_ID}", params={"path": p})
+ wait_task(task)
+
+print("\n" + "="*40)
+print("ALL FILESYSTEM TESTS COMPLETED SUCCESSFULLY")
+print("="*40)
diff --git a/tools/globus.py b/tools/globus.py
new file mode 100644
index 00000000..772a1b75
--- /dev/null
+++ b/tools/globus.py
@@ -0,0 +1,58 @@
+import globus_sdk
+import datetime
+import time
+import os
+
+GLOBUS_APP_ID = os.environ.get("GLOBUS_APP_ID")
+GLOBUS_APP_SECRET = os.environ.get("GLOBUS_APP_SECRET")
+GLOBUS_RS_ID = os.environ.get("GLOBUS_RS_ID")
+GLOBUS_RS_SCOPE_SUFFIX = os.environ.get("GLOBUS_RS_SCOPE_SUFFIX")
+GLOBUS_SCOPE = f"https://auth.globus.org/scopes/{GLOBUS_RS_ID}/{GLOBUS_RS_SCOPE_SUFFIX}"
+
+# Create a confidential client
+client = globus_sdk.ConfidentialAppAuthClient(GLOBUS_APP_ID, GLOBUS_APP_SECRET)
+
+# Start the OAuth flow
+client.oauth2_start_flow(
+ redirect_uri="http://localhost:5000/callback", # or your registered redirect URI
+ requested_scopes=["openid", "profile", "email", GLOBUS_SCOPE]
+)
+
+# Get the authorization URL
+authorize_url = client.oauth2_get_authorize_url(query_params={"prompt": "login"})
+print(f"Visit this URL in your browser:\n{authorize_url}\n")
+
+# After visiting the URL and authorizing, you'll be redirected to a URL with a code parameter
+auth_code = input("Paste the 'code' parameter from the redirect URL: ")
+
+# Exchange the code for tokens
+token_response = client.oauth2_exchange_code_for_tokens(auth_code)
+
+# Print all resource servers and their tokens
+print("\n=== ALL TOKENS ===")
+for resource_server, token_data in token_response.by_resource_server.items():
+ print(f"\nResource Server: {resource_server}")
+ print(f" Access Token: {token_data['access_token'][:50]}...")
+ print(f" Scope: {token_data.get('scope', 'N/A')}")
+ print(f" Expires at: {datetime.datetime.fromtimestamp(token_data['expires_at_seconds'])}")
+
+# Extract the IRI API token to send to the API
+if GLOBUS_RS_ID in token_response.by_resource_server:
+ iri_token_data = token_response.by_resource_server[GLOBUS_RS_ID]
+ iri_token = iri_token_data['access_token']
+ expires_at = iri_token_data['expires_at_seconds']
+
+ # Convert to human-readable time
+ expiration_time = datetime.datetime.fromtimestamp(expires_at)
+ print(f"\nIRI API token expires at: {expiration_time}")
+
+ # Calculate how long until expiration
+ seconds_until_expiration = expires_at - time.time()
+ hours_until_expiration = seconds_until_expiration / 3600
+ print(f"Token expires in {hours_until_expiration:.2f} hours")
+
+ print(f"\n=== USE THIS IRI API TOKEN ===")
+ print(f"IMPORTANT: You must log in with your NERSC-linked Globus identity")
+ print(f"\n{iri_token}")
+else:
+ print(f"\nERROR: No IRI API token found. Make sure you requested the correct scope.")
diff --git a/tools/manage_globus.py b/tools/manage_globus.py
new file mode 100644
index 00000000..818ef572
--- /dev/null
+++ b/tools/manage_globus.py
@@ -0,0 +1,71 @@
+"""
+This script manages the resource server for the data movement API. It allows you to view, create, and delete scopes
+for the resource server.
+
+It's only useful to admins of the data movement API.
+"""
+
+import typer
+import globus_sdk
+import pprint
+import os
+
+GLOBUS_RS_ID = os.environ.get("GLOBUS_RS_ID")
+GLOBUS_RS_SECRET = os.environ.get("GLOBUS_RS_SECRET")
+GLOBUS_RS_SCOPE_SUFFIX = os.environ.get("GLOBUS_RS_SCOPE_SUFFIX")
+GLOBUS_SCOPE = f"https://auth.globus.org/scopes/{GLOBUS_RS_ID}/{GLOBUS_RS_SCOPE_SUFFIX}"
+
+global client
+app = typer.Typer(no_args_is_help=True)
+
+
+@app.command()
+def client_show():
+ print(client.get_client(client_id=GLOBUS_RS_ID))
+
+
+@app.command()
+def scopes_show():
+ scope_ids = client.get_client(client_id=GLOBUS_RS_ID)["client"]["scopes"]
+ scopes = [client.get_scope(scope_id).data for scope_id in scope_ids]
+ pprint.pprint(scopes)
+
+
+@app.command()
+def scope_show(scope: str = None, scope_string: str = None):
+ if not scope and not scope_string:
+ scope_string = GLOBUS_SCOPE
+ if scope_string:
+ print(client.get_scopes(scope_strings=[scope_string]))
+ else:
+ print(client.get_scope(scope_id=scope))
+
+
+@app.command()
+def scope_create_iri():
+ if typer.confirm("Create a new IRI API scope?"):
+ print(
+ client.create_scope(
+ GLOBUS_RS_ID,
+ "IRI API",
+ "Access to IRI API services",
+ "iri_api",
+ advertised=True,
+ allows_refresh_token=True,
+ )
+ )
+
+
+@app.command()
+def scope_delete(scope: str):
+ print(client.delete_scope(scope_id=scope))
+
+
+if __name__ == "__main__":
+ if not GLOBUS_RS_SECRET:
+ print("Error: No CLIENT_SECRET detected on env. Please set 'export CLIENT_SECRET=your_secret'")
+ else:
+ globus_app = globus_sdk.ClientApp("manage-ap", client_id=GLOBUS_RS_ID, client_secret=GLOBUS_RS_SECRET)
+ client = globus_sdk.AuthClient(app=globus_app)
+ client.add_app_scope(globus_sdk.AuthClient.scopes.manage_projects)
+ app()