Skip to content
Open
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
28 changes: 25 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,10 +71,34 @@ A REST API to execute [teuthology commands](https://docs.ceph.com/projects/teuth

The documentation can be accessed at http://localhost:8082/docs after running the application.

Once you have teuthology-api running, authenticate by visiting `http://localhost:8082/login` through browser and follow the github authentication steps (this stores the auth token in browser cookies).
Once you have teuthology-api running, authenticate by visiting `http://localhost:8082/login` through browser and follow the github authentication steps (this stores the auth token in browser cookies). For headless/CLI/CI use, see **Device code API** below.

> Note: To test below endpoints locally, recommended flow is to login through browser (as mentioned above) and then send requests (and receive response) through interactive docs at `/docs`.

### Device code API (headless / CLI / CI)

For scripts, CI, or environments without a browser, use the GitHub device flow to obtain an access token.

1. **Start device flow** — get a user code and verification URL:

```bash
curl -X POST http://localhost:8082/login/device/code
```

Response example: `{"device_code": "...", "user_code": "ABCD-1234", "verification_uri": "https://github.com/login/device", "expires_in": 900, "interval": 5}`.

2. **Have the user authorize** — open `verification_uri` in a browser, enter `user_code`, and approve access.

3. **Poll for token** — call the token endpoint with the `device_code` from step 1 (query param `device_code`):

```bash
curl -X POST "http://localhost:8082/login/device/token?device_code=YOUR_DEVICE_CODE"
```

- **202** = authorization pending (poll again after `interval` seconds).
- **200** = success; response includes `access_token`. Use it in `X-Access-Token` or `Authorization: Bearer <access_token>` for other API calls.
- **403** = user not in Ceph org or access denied. **410** = code expired.

### Route `/`

```
Expand Down Expand Up @@ -109,5 +133,3 @@ Example
}'

Note: "--owner" in data body should be same as your github username (case sensitive). Otherwise, you wouldn't have permission to kill jobs/run.

xxx
1 change: 1 addition & 0 deletions pytest.ini
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
[pytest]
pythonpath = src
asyncio_mode = auto
log_cli = 1
log_cli_level = INFO
log_cli_format = %(asctime)s [%(levelname)8s] %(message)s (%(filename)s:%(lineno)s)
Expand Down
1 change: 1 addition & 0 deletions setup.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ testing =
setuptools
pytest
pytest-cov
pytest-asyncio

[options.entry_points]
# Add here console scripts like:
Expand Down
118 changes: 116 additions & 2 deletions src/teuthology_api/routes/login.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,17 +5,21 @@
from dotenv import load_dotenv
import httpx

from teuthology_api.services.helpers import isAdmin

load_dotenv()

GH_CLIENT_ID = os.getenv("GH_CLIENT_ID")
GH_CLIENT_SECRET = os.getenv("GH_CLIENT_SECRET")
GH_AUTHORIZATION_BASE_URL = os.getenv("GH_AUTHORIZATION_BASE_URL")
GH_TOKEN_URL = os.getenv("GH_TOKEN_URL")
GH_DEVICE_CODE_URL = os.getenv(
"GH_DEVICE_CODE_URL", "https://github.com/login/device/code"
)
GH_FETCH_MEMBERSHIP_URL = os.getenv("GH_FETCH_MEMBERSHIP_URL")
PULPITO_URL = os.getenv("PULPITO_URL")

# Scope for device flow (must include read:org for Ceph org membership check)
DEVICE_FLOW_SCOPE = "read:org"

log = logging.getLogger(__name__)
router = APIRouter(
prefix="/login",
Expand Down Expand Up @@ -88,6 +92,7 @@ async def handle_callback(code: str, request: Request):
"access_token": token,
}
request.session["user"] = data
from teuthology_api.services.helpers import isAdmin
isUserAdmin = await isAdmin(data["username"], data["access_token"])
data["isUserAdmin"] = isUserAdmin
cookie_data = {
Expand All @@ -101,3 +106,112 @@ async def handle_callback(code: str, request: Request):
response = RedirectResponse(PULPITO_URL)
response.set_cookie(key="GH_USER", value=cookie)
return response


# --- Device flow (headless / CLI / scripts) ---


@router.post("/device/code", status_code=200)
async def device_code():
"""
Start GitHub device flow. Returns device_code, user_code, verification_uri,
expires_in, and interval. The client should show the user the verification_uri
and user_code, then poll POST /login/device/token with the device_code until
the user authorizes or the code expires.
"""
if not GH_CLIENT_ID:
raise HTTPException(
status_code=500, detail="Environment secrets are missing (GH_CLIENT_ID)."
)
headers = {"Accept": "application/json"}
data = {"client_id": GH_CLIENT_ID, "scope": DEVICE_FLOW_SCOPE}
async with httpx.AsyncClient() as client:
response = await client.post(
url=GH_DEVICE_CODE_URL, data=data, headers=headers
)
if response.status_code != 200:
log.error("Device code request failed: %s", response.text)
raise HTTPException(
status_code=response.status_code,
detail=response.text or "Failed to get device code",
)
result = response.json()
if "error" in result:
log.error("Device code error: %s", result)
raise HTTPException(
status_code=400,
detail=result.get("error_description", result.get("error", "Unknown error")),
)
return {
"device_code": result.get("device_code"),
"user_code": result.get("user_code"),
"verification_uri": result.get("verification_uri"),
"expires_in": result.get("expires_in"),
"interval": result.get("interval", 5),
}


@router.post("/device/token", status_code=200)
async def device_token(device_code: str):
"""
Poll during device flow. Pass the device_code from POST /login/device/code.
Returns access_token when the user has authorized; otherwise raises with
authorization_pending, slow_down, expired_token, or access_denied.
"""
if not GH_CLIENT_ID:
raise HTTPException(
status_code=500, detail="Environment secrets are missing (GH_CLIENT_ID)."
)
headers = {"Accept": "application/json"}
data = {
"client_id": GH_CLIENT_ID,
"device_code": device_code,
"grant_type": "urn:ietf:params:oauth:grant-type:device_code",
}
async with httpx.AsyncClient() as client:
response = await client.post(url=GH_TOKEN_URL, data=data, headers=headers)
result = response.json()

if "error" in result:
error = result.get("error")
desc = result.get("error_description", error)
if error == "authorization_pending":
raise HTTPException(status_code=202, detail=desc)
if error == "slow_down":
raise HTTPException(status_code=429, detail=desc)
if error in ("expired_token", "token_expired"):
raise HTTPException(status_code=410, detail=desc)
if error == "access_denied":
raise HTTPException(status_code=403, detail=desc)
if error == "incorrect_device_code":
raise HTTPException(status_code=400, detail=desc)
raise HTTPException(status_code=400, detail=desc)

token = result.get("access_token")
if not token:
raise HTTPException(status_code=500, detail="No access_token in response")

# Validate user is in Ceph org (same as web callback)
headers = {"Authorization": "token " + token, "Accept": "application/json"}
async with httpx.AsyncClient() as client:
response_org = await client.get(
url=GH_FETCH_MEMBERSHIP_URL, headers=headers
)
if response_org.status_code == 404:
log.error("User is not part of the Ceph Organization")
raise HTTPException(
status_code=403,
detail="User is not part of the Ceph Organization, please contact admin",
)
if response_org.status_code != 200:
log.error("Membership check failed: %s", response_org.text)
raise HTTPException(
status_code=response_org.status_code,
detail="Failed to verify organization membership",
)

return {
"access_token": token,
"token_type": result.get("token_type", "bearer"),
"scope": result.get("scope"),
}
42 changes: 36 additions & 6 deletions src/teuthology_api/routes/suite.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import logging
from typing import Optional

from fastapi import APIRouter, HTTPException, Depends, Request
from fastapi import APIRouter, HTTPException, Header, Request

from teuthology_api.services.suite import run
from teuthology_api.services.helpers import get_token, get_username
from teuthology_api.services.helpers import resolve_access_token
from teuthology_api.schemas.suite import SuiteArgs

log = logging.getLogger(__name__)
Expand All @@ -16,12 +17,41 @@


@router.post("/", status_code=200)
def create_run(
async def create_run(
request: Request,
args: SuiteArgs,
access_token: str = Depends(get_token),
logs: bool = False,
access_token: Optional[str] = Header(
default=None,
alias="X-Access-Token",
description="GitHub access token for headless auth (alternative to Authorization header)",
),
):
"""
Schedule a teuthology suite run.

Authentication options (in order of precedence):
1. Authorization: Bearer <token> header (or X-Access-Token header)
2. Session cookie - browser-based login

For headless/CI usage, obtain a token via the device flow:
- POST /login/device/code -> get user_code and verification_uri
- User visits verification_uri and enters user_code
- POST /login/device/token -> poll until access_token is returned
- Use the access_token in X-Access-Token header or Authorization: Bearer header
"""
# Resolve token and username from various auth sources (never use body for --user)
username, token_dict = await resolve_access_token(request, access_token)

args = args.model_dump(by_alias=True)
args["--user"] = get_username(request)
return run(args, logs, access_token)
# Force both from auth: --user => run name + subprocess USER; --owner => scheduler ownership (kill permission)
args["--user"] = username
args["--owner"] = username
log.info("Scheduling suite as user: %s", username)
try:
created_run = run(args, logs, token_dict)
log.debug(created_run)
except Exception as e:
raise HTTPException(status_code=400, detail=str(e))
return created_run

Loading