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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,4 @@ test_compute.sh
fastapi.log
compute-tests/
local.env
test_filesystem.sh
4 changes: 3 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,11 @@ 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_API_ADAPTER_facility="app.s3df.facility_adapter.S3DFFacilityAdapter"
ENV IRI_API_ADAPTER_task="app.s3df.task_adapter.S3DFTaskAdapter"
ENV IRI_SHOW_MISSING_ROUTES="true"
ENV DEX_JWKS_URL="https://dex.slac.stanford.edu/keys"
ENV DEX_ISSUER="https://dex.slac.stanford.edu"

ENV API_URL_ROOT="https://sdf-iri-dev.slac.stanford.edu"

CMD ["fastapi", "run", "app/main.py", "--port", "8000"]
4 changes: 2 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -47,9 +47,9 @@ dev-s3df: deps
API_URL_ROOT='http://127.0.0.1:8000' fastapi dev

# --- Docker / GHCR targets ---
GHCR_USERNAME ?= ""
GHCR_USERNAME ?= slaclab
GHCR_IMAGE ?= ghcr.io/$(GHCR_USERNAME)/iri-s3df
IMAGE_TAG ?= dev
IMAGE_TAG ?= prod

docker-build:
docker build --platform linux/amd64 -t $(GHCR_IMAGE):$(IMAGE_TAG) .
Expand Down
6 changes: 4 additions & 2 deletions app/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,14 @@
For more information, see: [https://iri.science/](https://iri.science/)

<img src="https://iri.science/images/doe-icon-old.png" height=50 />

<img src="../logo/SLAC_primary_red.png" height=100 />
"""

# version is the openapi.json spec version
# /api/v1 mount point means it's the latest backward-compatible url
API_CONFIG = {
"title": "IRI Facility API reference implementation",
"title": "SLAC IRI API implementation",
"description": description,
"version": API_VERSION,
"docs_url": "/",
Expand All @@ -36,7 +38,7 @@
logger.error(f"Error parsing IRI_API_PARAMS: {exc}")


API_URL_ROOT = os.environ.get("API_URL_ROOT", "https://api.iri.nersc.gov")
API_URL_ROOT = os.environ.get("API_URL_ROOT", "https://iri.slac.stanford.edu")
API_PREFIX = os.environ.get("API_PREFIX", "/")
API_URL = os.environ.get("API_URL", "api/v1")

Expand Down
23 changes: 12 additions & 11 deletions app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
from app.routers.task import task

from . import config
from .request_context import set_api_url_base, _api_url_base
from .request_context import set_api_url_base, set_auth_headers, _api_url_base

logging.basicConfig(
level=logging.INFO,
Expand All @@ -48,34 +48,35 @@
tracer = trace.get_tracer(__name__)
# ------------------------------------------------------------------

APP = FastAPI(servers=[{"url": config.API_URL_ROOT}], **config.API_CONFIG)
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)
set_auth_headers(request)
return await call_next(request)
finally:
_api_url_base.reset(token)


APP.add_middleware(_ExternalRequestContextMiddleware)
app.add_middleware(_ExternalRequestContextMiddleware)

if config.OPENTELEMETRY_ENABLED:
FastAPIInstrumentor.instrument_app(APP)
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(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)
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}")
18 changes: 18 additions & 0 deletions app/request_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,15 @@

_api_url_base: ContextVar[str | None] = ContextVar("_api_url_base", default=None)

# Auth headers injected by the s3df-authnz Traefik middleware (ForwardAuth).
_AUTH_HEADER_NAMES = (
"x-auth-request-primary-gid",
"x-auth-request-gids",
'x-auth-request-uid'
)

_auth_headers: ContextVar[dict[str, str]] = ContextVar("_auth_headers", default={})


def set_api_url_base(request: Request) -> None:
"""Set the per-request API URL base from forwarding headers."""
Expand All @@ -21,6 +30,15 @@ def set_api_url_base(request: Request) -> None:
if host:
_api_url_base.set(f"{proto}://{host}{prefix}/{api_url}")

def set_auth_headers(request: Request) -> None:
"""Capture the authnz-injected headers from the current request into context."""
headers = {name: request.headers[name] for name in _AUTH_HEADER_NAMES if name in request.headers}
_auth_headers.set(headers)


def get_auth_headers() -> dict[str, str]:
"""Return the authnz-injected headers for the current request."""
return _auth_headers.get()

def get_url_prefix() -> str:
"""Return the per-request API URL base, or fall back to static config."""
Expand Down
4 changes: 2 additions & 2 deletions app/routers/account/models.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
"""Models for account-related API endpoints, including users, projects, and allocations."""
import datetime
from pydantic import Field, computed_field, field_validator

from typing import Optional
from ...request_context import get_url_prefix
from ...types.base import IRIBaseModel
from ...types.scalars import AllocationUnit
Expand All @@ -20,7 +20,7 @@ class Project(IRIBaseModel):
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")
last_modified: Optional[datetime.datetime] = Field(None, description="Timestamp of the last modification of the project.", example="2026-02-21T14:30:00Z")

@computed_field(description="URI to this project resource")
@property
Expand Down
11 changes: 6 additions & 5 deletions app/s3df/account_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ async def get_user(self, user_id: str, api_key: str, client_ip: str | None, glob
# AccountFacilityAdapter methods
# -------------------------------------------------------------------------

async def get_capabilities(self) -> list[Capability]:
async def get_capabilities(self, name: str | None = None, modified_since: str | None = None, offset: int = 0, limit: int = 1000) -> list[Capability]:
"""
coact.Cluster → IRI.Capability (compute)
Static storage types → IRI.Capability (storage)
Expand Down Expand Up @@ -114,11 +114,10 @@ async def get_projects(self, user: User) -> list[account_models.Project]:

for repo in repos:
all_users = set(repo.get("users", []) + repo.get("leaders", []) + [repo.get("principal", "")])
# if user.id in all_users:


projects.append(account_models.Project(
id=repo["Id"],
name=repo["name"],
name=f"{repo['facility']}:{repo['name']}",
description=repo.get("description", ""),
user_ids=list(all_users)
))
Expand Down Expand Up @@ -146,9 +145,11 @@ async def get_project_allocations(
allocations = []

# Map compute allocations
if len(repo_allocations) == 0:
raise HTTPException(status_code=404, detail=f"No compute allocations found for project {project.id}")
for comp_alloc in repo_allocations:
# comp_alloc_usage = [usage for usage in COACT_REPO_OVERALL_COMPUTE_USAGE if usage["allocation_id"] == comp_alloc["_id"]][0]
overall_usage = comp_alloc['usage'][0]
overall_usage = comp_alloc['usage'][0] if comp_alloc.get('usage') else None
allocations.append(account_models.ProjectAllocation(
id=comp_alloc["_id"],
project_id=project.id,
Expand Down
87 changes: 39 additions & 48 deletions app/s3df/clients/coact.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,56 +26,56 @@ def __init__(
api_url: Optional[str] = None,
service_user: Optional[str] = None,
service_password: Optional[str] = None,
use_basic_auth: bool = False
):
"""
Initialize the coact GraphQL client.

Args:
api_url: GraphQL endpoint URL (defaults to settings.coact_api_url)
service_user: Service account username (defaults to settings.coact_service_user)
service_password: Service account password (for basic auth)
use_basic_auth: If True, use Basic auth instead of coactimp headers
service_password: Service account password for Basic auth through nginx
"""
self.api_url = api_url or settings.coact_api_url
self.service_user = service_user or settings.coact_service_user
self.service_password = service_password
self.use_basic_auth = use_basic_auth
self._client: Optional[Client] = None

LOG.info(f"Initialized CoactClient for endpoint: {self.api_url} (service_user={self.service_user}, use_basic_auth={self.use_basic_auth})")
LOG.info(f"Initialized CoactClient for endpoint: {self.api_url} (service_user={self.service_user})")

def _get_client(self, username: Optional[str] = None) -> Client:
"""
Get or create a GQL client with appropriate headers.
Create a GQL client with appropriate headers for each request.

Always authenticates the service account via Basic auth (for nginx).
When ``username`` is provided, also sets the ``coactimp`` header so
coact-api executes the query in the context of that user.

Args:
username: Username for impersonation (coactimp mode) or ignored (basic auth mode)
username: End-user username to impersonate via coactimp. Pass None
for service-account-level (admin) queries.

Returns:
Configured GQL Client instance
"""
headers = {"Content-Type": "application/json"}
if not self.service_password:
raise ValueError("service_password is required")

if self.use_basic_auth:
# Basic Authentication for /graphql-service endpoint
if not self.service_password:
raise ValueError("service_password required when use_basic_auth=True")

credentials = base64.b64encode(f"{self.service_user}:{self.service_password}".encode()).decode("ascii")
print(f"Calling uri: {self.api_url} as {self.service_user} with basic auth")
headers["Authorization"] = f"Basic {credentials}"

LOG.debug(f"Using Basic auth as {self.service_user}")
else:
# UI-style headers for /graphql endpoint
headers["coactimp"] = username or "null"
credentials = base64.b64encode(
f"{self.service_user}:{self.service_password}".encode()
).decode("ascii")

headers = {
"Content-Type": "application/json",
"Authorization": f"Basic {credentials}",
}

if username:
headers["coactimp"] = username
headers["coactshowall"] = "true"

if username:
LOG.debug(f"Setting coactimp header for user: {username}")
LOG.debug(f"Impersonating user via coactimp: {username}")
else:
LOG.debug(f"Running as service account: {self.service_user}")

transport = HTTPXAsyncTransport( # changed from HTTPXTransport
transport = HTTPXAsyncTransport(
url=self.api_url,
headers=headers,
timeout=30.0
Expand Down Expand Up @@ -222,9 +222,7 @@ async def get_user_from_lookup_service(self, username: str) -> Optional[Dict[str
result = await self.execute_query(
query,
variables={"filter": {"username": username}},
username=self.service_user
)
print(result)
users = result.get("usersLookupFromService", [])
return users[0] if users else None
except Exception as e:
Expand Down Expand Up @@ -258,7 +256,6 @@ async def get_access_groups_for_repo(self, repo_id: str) -> List[Dict[str, Any]]
result = await self.execute_query(
query,
variables={"filter": {"repoid": repo_id}},
username=self.service_user
)
return result.get("access_groups", [])
except Exception as e:
Expand Down Expand Up @@ -510,7 +507,6 @@ async def get_repo_compute_allocations(self, repo_id: str) -> Optional[Dict[str,
result = await self.execute_query(
query,
variables={"repoId": repo_id},
username=self.service_user # Use service user for this query
)
repo_allocations = result.get("repos", [])
if not repo_allocations:
Expand Down Expand Up @@ -645,7 +641,6 @@ async def get_user_allocation(
result = await self.execute_query(
query,
variables={"repoId": repo_id, "allocationId": allocation_id},
username=self.service_user
)
repo = result.get("repos")
if not repo:
Expand Down Expand Up @@ -799,29 +794,26 @@ async def get_all_repos(self) -> List[Dict[str, Any]]:
"""

try:
result = await self.execute_query(
query,
username=self.service_user
)
result = await self.execute_query(query)
return result.get("repos", [])
except Exception as e:
LOG.error(f"Failed to get repos for user {self.service_user}: {e}")
except Exception as e:
LOG.error(f"Failed to get all repos: {e}")
return []

async def get_user_repos(self, username: str) -> List[Dict[str, Any]]:
"""
Get repos for a user.
Get repos for a user by impersonating them via coactimp.

Delegates to get_my_repos so coact-api performs server-side filtering,
returning only the repos the user belongs to.

Args:
username: Username to query repos for

Returns:
List of repo objects
"""
repos = await self.get_all_repos()
user_repos = [repo for repo in repos if username in repo.get("users", []) or username in repo.get("leaders", []) or username == repo.get("principal")]

return user_repos
return await self.get_my_repos(username)

async def get_all_repos_and_facility(self) -> List[Dict[str, Any]]:
"""
Expand All @@ -838,11 +830,11 @@ async def get_all_repos_and_facility(self) -> List[Dict[str, Any]]:
}
}
"""
try:
result = await self.execute_query(query, username=self.service_user)
try:
result = await self.execute_query(query)
return result.get("allreposandfacility", [])
except Exception as e:
LOG.error(f"Failed to get all repos: {e}")
LOG.error(f"Failed to get all repos and facility: {e}")
return []


Expand All @@ -853,7 +845,7 @@ async def get_all_repos_and_facility(self) -> List[Dict[str, Any]]:
def get_coact_client() -> CoactClient:
"""
Get or create the default CoactClient instance.
Reads basic auth settings from S3DFSettings.
Reads connection settings from S3DFSettings.

Returns:
Singleton CoactClient instance
Expand All @@ -865,7 +857,6 @@ def get_coact_client() -> CoactClient:
api_url=settings.coact_api_url,
service_user=settings.coact_service_user,
service_password=settings.coact_service_password,
use_basic_auth=True,
)

return _default_client
Loading
Loading