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>
This commit is contained in:
@@ -0,0 +1,103 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user