`pip install fluksio && fluksio serve` on a machine with no Docker, no database and no configuration — which is the case this is for: a node on a cluster where ports cannot be opened. It makes its data directory, its key and an admin account, prints the password once, and serves. Pairing is `fluksio enroll <code> --portal …`, doing what the Settings screen does through the same function, before the engine starts and without one running — a machine nobody can route to has no browser pointed at it either. The portal serves the dashboard, so nothing is served here. Two things had to give way. `fastapi[standard]` pulls a cloud CLI that wants sentry-sdk 2.x while we pinned below it — no pip resolution existed, so the pin is lifted, which the comment beside it had been waiting for and which also lets the Python cap go. And `uv` is now a dependency rather than something to find on PATH: the Modules screen is how a data scientist installs torch, and it was quietly falling back to the engine's own interpreter. The CLI imports nothing from the engine before it has set DATA_DIR — the settings are built on the first import of core.config, and reaching it early put the database in the working directory. There is a test for that now, because the failure is silent. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
104 lines
3.7 KiB
Python
104 lines
3.7 KiB
Python
"""Redeeming a claim code, from the dashboard or from the command line.
|
|
|
|
The work is the same either way — ask the portal, keep what it answers, and
|
|
map the account that asked to the portal identity that owns the installation —
|
|
so it lives here rather than in the route. The command line matters because a
|
|
machine on a cluster has no browser pointed at it: `fluksio enroll` does this
|
|
before the engine has started, holding nothing but the database.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from datetime import datetime, timezone
|
|
from typing import Any
|
|
|
|
import httpx
|
|
from sqlmodel import Session, select
|
|
|
|
import fluksio
|
|
from fluksio.cloud import config as cloud_config
|
|
from fluksio.models import User
|
|
|
|
|
|
class EnrollError(Exception):
|
|
"""A failure with the status the API should answer with."""
|
|
|
|
def __init__(self, status: int, detail: str) -> None:
|
|
super().__init__(detail)
|
|
self.status = status
|
|
self.detail = detail
|
|
|
|
|
|
class AlreadyEnrolled(EnrollError):
|
|
def __init__(self) -> None:
|
|
super().__init__(409, "This installation is already connected to a portal")
|
|
|
|
|
|
def redeem_claim(
|
|
portal_url: str, claim_code: str, *, timeout: float = 15.0
|
|
) -> dict[str, Any]:
|
|
"""Trade a claim code for this installation's credential and the portal's keys."""
|
|
base = portal_url.rstrip("/")
|
|
try:
|
|
response = httpx.post(
|
|
f"{base}/api/v1/enroll/",
|
|
json={"claim_code": claim_code, "app_version": fluksio.__version__},
|
|
timeout=timeout,
|
|
)
|
|
except httpx.HTTPError as exc:
|
|
raise EnrollError(502, f"Could not reach the portal: {exc}") from exc
|
|
if response.status_code == 404:
|
|
raise EnrollError(400, "That claim code is unknown or has expired")
|
|
if response.status_code != 200:
|
|
raise EnrollError(502, f"The portal refused the claim ({response.status_code})")
|
|
|
|
data: dict[str, Any] = response.json()
|
|
if not data.get("owner_id"):
|
|
# A portal older than remote users does not say who owns the
|
|
# installation, and without that the enrolling account cannot be mapped
|
|
# to anyone — which would leave the portal connected but refused here.
|
|
raise EnrollError(
|
|
502,
|
|
"That portal is too old for this installation: it did not say "
|
|
"which account owns the installation",
|
|
)
|
|
return data
|
|
|
|
|
|
def enroll(
|
|
session: Session, user: User, portal_url: str, claim_code: str
|
|
) -> cloud_config.CloudConfig:
|
|
"""Redeem the code and write the config the connector dials with."""
|
|
if cloud_config.exists():
|
|
raise AlreadyEnrolled()
|
|
|
|
data = redeem_claim(portal_url, claim_code)
|
|
config = cloud_config.CloudConfig(
|
|
portal_url=portal_url.rstrip("/"),
|
|
ws_url=data["ws_url"],
|
|
installation_id=data["installation_id"],
|
|
token=data["installation_token"],
|
|
issuer=data["issuer"],
|
|
# Pinned here, at the one moment the claim code proves who we are
|
|
# talking to. Nothing refreshes this.
|
|
jwks=data["jwks"],
|
|
local_user_id=str(user.id),
|
|
enrolled_at=datetime.now(timezone.utc).isoformat(),
|
|
portal_account=user.email,
|
|
)
|
|
cloud_config.save(config)
|
|
|
|
owner_id = str(data["owner_id"])
|
|
# Re-enrolling from a different local account moves the mapping rather than
|
|
# leaving two accounts claiming the same portal identity, which the unique
|
|
# index would refuse and the lookup could not choose between anyway.
|
|
for other in session.exec(
|
|
select(User).where(User.portal_sub == owner_id, User.id != user.id)
|
|
):
|
|
other.portal_sub = None
|
|
session.add(other)
|
|
user.portal_sub = owner_id
|
|
session.add(user)
|
|
session.commit()
|
|
return config
|