Docs / docs (push) Successful in 27s
Playwright Tests / test-playwright (1, 2) (push) Failing after 17s
Playwright Tests / test-playwright (2, 2) (push) Failing after 12s
pre-commit / pre-commit (push) Failing after 1m59s
Test Backend / test-backend (push) Failing after 2m30s
Compose Smoke Test / test-compose (push) Failing after 13s
Playwright Tests / merge-reports (push) Failing after 2m19s
serve: refuse a second engine for one data directory whatever port it was asked for, using the pidfile and a token this directory signed. The check runs before the database is touched and before the credential is written, which is what left every later CLI call pointing at a dead port. The terminal dashboard is three tabs (Overview, Runs, Logs) with the toolbar following the focused pane, the engine's output goes to serve.log rather than down a pipe, and closing the screen stops both reader threads so the prompt comes back. It adopts a running engine on every start, so stop/start and restart work on one it did not start, and a stop waits for the process to be gone before the next start. Enrolment reports itself in the modal. enroll: a new claim code replaces the pairing instead of being refused. The code is redeemed before anything is written, mappings to a portal being left are cleared, and a running engine redials when the stored enrolment changes. runs: an engine re-queues the runs left `queued` by the one before it, and `fluksio retry <id>` / `retry --group <sweep>` submits an interrupted run again with the same inputs and group, recorded through Run.parent_id. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01U9BoNGq6V9MdRWAte7JBuC
130 lines
5.0 KiB
Python
130 lines
5.0 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 instance —
|
|
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 UTC, datetime
|
|
from typing import Any
|
|
from urllib.parse import urlsplit
|
|
|
|
import httpx
|
|
from sqlmodel import Session, col, 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
|
|
|
|
|
|
def _is_local(url: str) -> bool:
|
|
"""Whether the address is this machine or a compose-internal service."""
|
|
host = urlsplit(url).hostname or ""
|
|
return host in {"localhost", "127.0.0.1", "::1"} or host.endswith(".local")
|
|
|
|
|
|
def redeem_claim(
|
|
portal_url: str, claim_code: str, *, timeout: float = 15.0
|
|
) -> dict[str, Any]:
|
|
"""Trade a claim code for this instance's credential and the portal's keys."""
|
|
base = portal_url.rstrip("/")
|
|
# The claim code and, from here on, this instance's credential go to
|
|
# this address. Over plain http both are readable by anything on the path,
|
|
# so refuse rather than enrol insecurely — bar a loopback portal, which is
|
|
# how the stack is developed against itself.
|
|
if not base.startswith("https://") and not _is_local(base):
|
|
raise EnrollError(
|
|
422, "The portal address must be https:// (or a local address)"
|
|
)
|
|
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
|
|
# instance, 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 instance: it did not say "
|
|
"which account owns the instance",
|
|
)
|
|
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.
|
|
|
|
An instance that is already paired is re-paired rather than refused: a new
|
|
claim code is somebody asking for this, and refusing left deleting
|
|
`cloud.json` by hand as the only way through. The code is redeemed before
|
|
anything is written, so a code the portal rejects leaves a working
|
|
connection working.
|
|
"""
|
|
previous = cloud_config.load()
|
|
data = redeem_claim(portal_url, claim_code)
|
|
config = cloud_config.CloudConfig(
|
|
portal_url=portal_url.rstrip("/"),
|
|
ws_url=data["ws_url"],
|
|
instance_id=data["instance_id"],
|
|
token=data["instance_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(UTC).isoformat(),
|
|
portal_account=user.email,
|
|
)
|
|
cloud_config.save(config)
|
|
|
|
owner_id = str(data["owner_id"])
|
|
if previous is not None and previous.issuer != config.issuer:
|
|
# A mapping is a subject of the portal that issued it, and nothing in
|
|
# the row says which portal that was — so against a different portal
|
|
# the old rows would both block the new owner from being adopted and
|
|
# resolve a stranger's subject onto a local account.
|
|
for stale in session.exec(
|
|
select(User).where(col(User.portal_sub).is_not(None))
|
|
):
|
|
stale.portal_sub = None
|
|
session.add(stale)
|
|
session.flush()
|
|
# 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
|