Files
app/backend/fluksio/cloud/enroll.py
T
stroblmeandClaude Opus 5 10b0ba9e49 Add the fluksio CLI: serve, enroll, worker
`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>
2026-08-21 22:51:13 +02:00

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