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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ RUN pip install -U pip wheel setuptools uv && \
uv pip install --system .

ENV IRI_API_ADAPTER_account="app.s3df.account_adapter.S3DFAccountAdapter"
ENV IRI_API_ADAPTER_status="app.s3df.status_adapter.S3DFStatusAdapter"
ENV IRI_API_ADAPTER_compute="app.s3df.compute_adapter.SLACComputeAdapter"
ENV IRI_API_ADAPTER_filesystem="app.s3df.filesystem_adapter.S3DFFilesystemAdapter"
ENV IRI_SHOW_MISSING_ROUTES="true"
ENV DEX_JWKS_URL="https://dex.slac.stanford.edu/keys"
ENV DEX_ISSUER="https://dex.slac.stanford.edu"
Expand Down
1 change: 1 addition & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ dev: deps
dev-s3df: deps
@source $(BIN)/activate && \
IRI_API_ADAPTER_account=app.s3df.account_adapter.S3DFAccountAdapter \
IRI_API_ADAPTER_status=app.s3df.status_adapter.S3DFStatusAdapter \
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
Expand Down
28 changes: 26 additions & 2 deletions app/routers/status/status.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,10 +36,34 @@ async def get_resources(
resource_type: models.ResourceType = Query(default=None),
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"})),
site_id: str | None = Query(default=None, min_length=1),
_forbid=Depends(
forbidExtraQueryParams(
"name",
"description",
"group",
"offset",
"limit",
"modified_since",
"resource_type",
"current_status",
"capability",
"site_id",
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
offset=offset,
limit=limit,
name=name,
description=description,
group=group,
modified_since=modified_since,
resource_type=resource_type,
current_status=current_status,
capability=capability,
site_id=site_id,
)


Expand Down
3 changes: 2 additions & 1 deletion app/s3df/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from .account_adapter import S3DFAccountAdapter
from .facility_adapter import S3DFFacilityAdapter
from .compute_adapter import SLACComputeAdapter
from .status_adapter import S3DFStatusAdapter
from .config import settings

__all__ = ["S3DFAccountAdapter", "S3DFFacilityAdapter", "SLACComputeAdapter", "settings"]
__all__ = ["S3DFAccountAdapter", "S3DFFacilityAdapter", "SLACComputeAdapter", "S3DFStatusAdapter", "settings"]
4 changes: 2 additions & 2 deletions app/s3df/auth/jwt_verifier.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
import asyncio
import logging
import ssl
from typing import Optional
from typing import List, Optional, Union

import jwt
from fastapi import HTTPException
Expand All @@ -29,7 +29,7 @@ def __init__(
self,
jwks_url: str,
issuer: str,
audience: str,
audience: Union[str, List[str]],
username_claim: str = "name",
):
self.jwks_url = jwks_url
Expand Down
21 changes: 21 additions & 0 deletions app/s3df/clients/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,29 @@
"""

from app.s3df.clients.coact import CoactClient, get_coact_client
from app.s3df.clients.fs_facade import (
FsFacadeClient,
FsFacadeError,
FsFacadeTimeout,
get_fs_facade_client,
)
from app.s3df.clients.s3df_status_api import (
S3DFStatusApiClient,
S3DFStatusApiError,
get_s3df_status_api_client,
)
from app.s3df.clients.user_lookup import UserLookupClient, get_user_lookup_client

__all__ = [
"CoactClient",
"get_coact_client",
"FsFacadeClient",
"FsFacadeError",
"FsFacadeTimeout",
"get_fs_facade_client",
"S3DFStatusApiClient",
"S3DFStatusApiError",
"get_s3df_status_api_client",
"UserLookupClient",
"get_user_lookup_client",
]
170 changes: 170 additions & 0 deletions app/s3df/clients/fs_facade.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
"""
fs-facade-service Client

Async client for the filesystem facade microservice. Submits a filesystem
operation, polls the task endpoint until terminal state, and returns the
parsed JSON ``result`` payload.

The microservice exposes a task-queue API:
* ``POST/GET/PUT/DELETE /filesystem/{op}`` -> returns a bare ``task_id`` string
* ``GET /task/{task_id}`` -> returns ``{"output": Task{id,status,result,command}}``
where ``result`` is itself a JSON-encoded string (set by the dispatcher).

See: ``fs-facade-service/app/controllers/{filesystem_controller,task_controller}.py``.
"""

import asyncio
import json
import logging
from typing import Any, Optional

import httpx

from app.s3df.config import settings

LOG = logging.getLogger(__name__)


class FsFacadeError(Exception):
"""Raised when fs-facade returns an error or a task fails."""


class FsFacadeTimeout(Exception):
"""Raised when polling exceeds the configured timeout."""


_TERMINAL = {"completed", "failed", "canceled"}


class FsFacadeClient:
"""Async client for fs-facade-service."""

def __init__(
self,
base_url: str | None = None,
poll_interval: float | None = None,
timeout: float | None = None,
):
self.base_url = (base_url or settings.fs_facade_url).rstrip("/")
self.poll_interval = poll_interval if poll_interval is not None else settings.fs_facade_poll_interval
self.timeout = timeout if timeout is not None else settings.fs_facade_timeout
self._client: httpx.AsyncClient | None = None
LOG.info(f"Initialized FsFacadeClient for endpoint: {self.base_url}")

def _get_client(self) -> httpx.AsyncClient:
if self._client is None:
self._client = httpx.AsyncClient(base_url=self.base_url, timeout=self.timeout)
return self._client

async def aclose(self) -> None:
if self._client is not None:
await self._client.aclose()
self._client = None

async def _request_task_id(self, method: str, path: str, **kwargs) -> str:
"""Issue an HTTP request that returns a bare task_id string."""
client = self._get_client()
try:
resp = await client.request(method, path, **kwargs)
except httpx.HTTPError as exc:
raise FsFacadeError(f"fs-facade transport error: {exc}") from exc
if resp.status_code >= 400:
raise FsFacadeError(
f"fs-facade {method} {path} -> {resp.status_code}: {resp.text}"
)
data = resp.json()
# The controllers return either a bare string (response_model=str) or
# `{"task_id": "..."}` (the create_task endpoint). Accept both shapes.
if isinstance(data, str):
return data
if isinstance(data, dict) and "task_id" in data:
return data["task_id"]
raise FsFacadeError(f"fs-facade returned unexpected payload: {data!r}")

async def get_task(self, task_id: str) -> dict:
"""Fetch the current task record."""
client = self._get_client()
try:
resp = await client.get(f"/task/{task_id}")
except httpx.HTTPError as exc:
raise FsFacadeError(f"fs-facade transport error: {exc}") from exc
if resp.status_code == 404:
raise FsFacadeError(f"fs-facade task not found: {task_id}")
if resp.status_code >= 400:
raise FsFacadeError(
f"fs-facade GET /task/{task_id} -> {resp.status_code}: {resp.text}"
)
body = resp.json()
# Wrapped in `{"output": Task{...}}` per the task_controller.
return body.get("output", body) if isinstance(body, dict) else body

async def wait(self, task_id: str, timeout: float | None = None) -> dict:
"""Poll until the task reaches a terminal state. Returns the Task dict."""
deadline_left = timeout if timeout is not None else self.timeout
elapsed = 0.0
while True:
task = await self.get_task(task_id)
status = task.get("status")
if status in _TERMINAL:
return task
if elapsed >= deadline_left:
raise FsFacadeTimeout(
f"Timed out waiting for fs-facade task {task_id} (status={status})"
)
await asyncio.sleep(self.poll_interval)
elapsed += self.poll_interval

async def call(
self,
method: str,
path: str,
*,
params: dict | None = None,
json_body: Any | None = None,
files: dict | None = None,
data: dict | None = None,
timeout: float | None = None,
) -> Any:
"""Submit an operation, wait for terminal state, and return the parsed result.

On success returns the JSON-decoded ``result`` (or the raw string when
the dispatcher returned a non-JSON payload). On failure raises
``FsFacadeError``.
"""
kwargs: dict[str, Any] = {}
if params is not None:
kwargs["params"] = params
if json_body is not None:
kwargs["json"] = json_body
if files is not None:
kwargs["files"] = files
if data is not None:
kwargs["data"] = data

task_id = await self._request_task_id(method, path, **kwargs)
task = await self.wait(task_id, timeout=timeout)

status = task.get("status")
result = task.get("result")
if status != "completed":
raise FsFacadeError(
f"fs-facade task {task_id} ended with status={status}: {result}"
)

if isinstance(result, str):
try:
return json.loads(result)
except (TypeError, ValueError):
return result
return result


_default_client: Optional[FsFacadeClient] = None


def get_fs_facade_client() -> FsFacadeClient:
"""Get or create the singleton FsFacadeClient instance."""
global _default_client
if _default_client is None:
_default_client = FsFacadeClient()
return _default_client
Loading
Loading